Merge branch 'stable-3.2' into stable-3.3

* stable-3.2:
  Make the indexing operation fail upon StorageException(s)
  ConsistencyCheckerIT: Delete calls to index broken changes that fail silently
  RefAdvertisementIT: Don't call reindex that would fail silently

Release-Notes: skip
Change-Id: Iecdf1e4ce450c8fab5244338ce46e6e4d95bf959
diff --git a/.bazelrc b/.bazelrc
index e560f2397..72138d2 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,8 +1,8 @@
-build --workspace_status_command="python3 ./tools/workspace_status.py" --strategy=Closure=worker
+build --workspace_status_command="python3 ./tools/workspace_status.py"
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
-build --java_toolchain //tools:error_prone_warnings_toolchain
+build --java_toolchain=//tools:error_prone_warnings_toolchain
 
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
@@ -13,5 +13,6 @@
 
 test --build_tests_only
 test --test_output=errors
+test --java_toolchain=//tools:error_prone_warnings_toolchain
 
 import %workspace%/tools/remote-bazelrc
diff --git a/.bazelversion b/.bazelversion
index 6aba2b2..af8c8ec 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-4.2.0
+4.2.2
diff --git a/.gitignore b/.gitignore
index 1e91c8f..00a6217 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@
 *.swp
 *~
 .DS_Store
+js-to-ts.sh
 /.apt_generated
 /.apt_generated_tests
 /.bazel_path
@@ -19,7 +20,6 @@
 /.settings/org.eclipse.ltk.core.refactoring.prefs
 /.settings/org.eclipse.m2e.core.prefs
 /.settings/org.maven.ide.eclipse.prefs
-/.ts-out
 /.vscode
 /bazel-*
 /bin/
@@ -32,6 +32,8 @@
 /node_modules/
 /package-lock.json
 /plugins/*
+!/plugins/package.json
+!/plugins/yarn.lock
 !/plugins/BUILD
 !/plugins/codemirror-editor
 !/plugins/commit-message-length-validator
@@ -50,3 +52,5 @@
 /tools/maven/gerrit-*_pom.xml.asc
 /tools/node_tools
 /tools/polygerrit-updater
+/.ts-out/*
+!/.ts-out/README.md
diff --git a/.ts-out/README.md b/.ts-out/README.md
new file mode 100644
index 0000000..dada30d
--- /dev/null
+++ b/.ts-out/README.md
@@ -0,0 +1,4 @@
+This directory contains compiled js code. Typescript uses subdirectories
+as output directories when runs under IDE.
+
+Bazel doesn't use this directory
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 71c9330..e2d3c6a 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -100,7 +100,7 @@
 Gerrit comes with two predefined groups:
 
 * Administrators
-* Non-Interactive Users
+* Service Users
 
 
 [[administrators]]
@@ -115,9 +115,10 @@
 Gerrit administrators, despite the group name. The group may also be
 renamed.
 
+anchor:non-interactive_users[]
 
-[[non-interactive_users]]
-=== Non-Interactive Users
+[[service_users]]
+=== Service Users
 
 This is the Gerrit "batch" identity. The capabilities
 link:access-control.html#capability_priority['Priority BATCH'] and
@@ -131,10 +132,11 @@
 order to prevent it from grabbing threads from the interactive users.
 
 These users live in a second thread pool, which separates operations
-made by the non-interactive users from the ones made by the interactive
+made by the service users from the ones made by the interactive
 users. This ensures that the interactive users can keep working when
 resources are tight.
 
+Before Gerrit 3.3, the 'Service Users' group was named 'Non-Interactive Users'.
 
 == Account Groups
 
@@ -186,6 +188,10 @@
 project "`All-Projects`".  This inheritance can be configured
 through link:cmd-set-project-parent.html[gerrit set-project-parent].
 
+When projects are set as parent projects, the child projects inherit
+all of the parent's access rights. "`All-Projects`" is treated as a
+parent of all projects.
+
 Per-project access control lists are also supported.
 
 Users are permitted to use the maximum range granted to any of their
@@ -865,6 +871,11 @@
 private changes (even without having the `View Private Changes` access
 right assigned).
 
+**NOTE**: If link:config-gerrit.html#auth.skipFullRefEvaluationIfAllRefsAreVisible[
+auth.skipFullRefEvaluationIfAllRefsAreVisible] is `true` (which is the case by
+default) privates changes and all change edit refs are also visible to users
+that have read access on `refs/*`.
+
 [[category_toggle_work_in_progress_state]]
 === Toggle Work In Progress state
 
@@ -1319,7 +1330,7 @@
 This capability allows the granted group members to create non-interactive
 service accounts.  These service accounts are generally used for automation
 and made to be members of the
-link:access-control.html#non-interactive_users['Non-Interactive users'] group.
+link:access-control.html#service_users['Service users'] group.
 
 
 [[capability_createGroup]]
@@ -1398,7 +1409,7 @@
 
 This capability allows users to use
 link:config-gerrit.html#sshd.batchThreads[the thread pool reserved] for
-link:access-control.html#non-interactive_users['Non-Interactive Users'].
+link:access-control.html#service_users['Service Users'].
 It's a binary value in that granted users either have access to the thread
 pool, or they don't.
 
@@ -1410,7 +1421,7 @@
 default the user is then in the 'INTERACTIVE' thread pool.
 
 'BATCH'::
-If there's a thread pool configured for 'Non-Interactive Users' and a user is
+If there's a thread pool configured for 'Service Users' and a user is
 granted the priority capability with the 'BATCH' mode selected, the user ends
 up in the separate batch user thread pool. This is true unless the user is
 also granted the below 'INTERACTIVE' option.
diff --git a/Documentation/cmd-create-account.txt b/Documentation/cmd-create-account.txt
index 617191f..502b7a7 100644
--- a/Documentation/cmd-create-account.txt
+++ b/Documentation/cmd-create-account.txt
@@ -23,7 +23,7 @@
 or event monitoring over link:cmd-stream-events.html[gerrit stream-events].
 
 Note, however, that in this case the account is not implicitly added
-to the 'Non-Interactive Users' group.  The account must be explicitly
+to the 'Service Users' group.  The account must be explicitly
 added to the group with the `--group` option.
 
 If LDAP authentication is being used, the user account is created
@@ -66,10 +66,10 @@
 
 == EXAMPLES
 Create a new batch/role access user account called `watcher` in
-the 'Non-Interactive Users' group.
+the 'Service Users' group.
 
 ----
-$ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit create-account --group "'Non-Interactive Users'" --ssh-key - watcher
+$ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit create-account --group "'Service Users'" --ssh-key - watcher
 ----
 
 GERRIT
diff --git a/Documentation/cmd-hook-commit-msg.txt b/Documentation/cmd-hook-commit-msg.txt
index 2b6d7af..e547822 100644
--- a/Documentation/cmd-hook-commit-msg.txt
+++ b/Documentation/cmd-hook-commit-msg.txt
@@ -56,6 +56,26 @@
 The `Change-Id` will not be added if `gerrit.createChangeId` is set
 to `false` in the git config.
 
+If `gerrit.reviewUrl` is set to the base URL of the Gerrit server that
+changes are uploaded to (e.g. `https://gerrit-review.googlesource.com/`)
+in the git config, then instead of adding a `Change-Id` trailer, a `Link`
+trailer will be inserted that will look like this:
+
+----
+Improve foo widget by attaching a bar.
+
+We want a bar, because it improves the foo by providing more
+wizbangery to the dowhatimeanery.
+
+Link: https://gerrit-review.googlesource.com/id/Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
+Signed-off-by: A. U. Thor <author@example.com>
+----
+
+This link will become a valid link to the review page once the change is
+uploaded to the Gerrit server. Newer versions of the Gerrit server will read
+the change identifier out of the appropriate `Link` trailer and treat it in
+the same way as the change identifier in a `Change-Id` trailer.
+
 == OBTAINING
 
 To obtain the `commit-msg` script use `scp`, `wget` or `curl` to download
diff --git a/Documentation/cmd-ls-groups.txt b/Documentation/cmd-ls-groups.txt
index 8a4845c..0a06c28 100644
--- a/Documentation/cmd-ls-groups.txt
+++ b/Documentation/cmd-ls-groups.txt
@@ -11,7 +11,7 @@
   [--user <NAME> | -u <NAME>]
   [--owned]
   [--visible-to-all]
-  [-q <GROUP>]
+  [-g <GROUP>]
   [--verbose | -v]
 --
 
@@ -67,8 +67,8 @@
 	(groups that are explicitly marked as visible to all registered
 	users).
 
--q::
-	Group that should be inspected. The `-q` option can be specified
+-g::
+	Group that should be inspected. The `-g` option can be specified
 	multiple times to define several groups to be inspected. If
 	specified the listed groups will only contain groups that were
 	specified to be inspected. This is e.g. useful in combination with
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 1d275b4..6de787c 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -14,7 +14,7 @@
 
 * Current and previous patch sets
 * <<Change properties>>, such as owner, project, and target branch
-* link:CONCEPT-comments.html[Comments]
+* Comments
 * Votes on link:config-labels.html[Review Labels]
 * The <<change-id>>
 
@@ -203,6 +203,34 @@
 method uses git's link:cmd-hook-commit-msg.html[commit-msg hook]
 to automatically add the Change-Id to each new commit.
 
+== The Link footer
+
+Gerrit also supports the Link footer as an alternative to the Change-Id
+footer. A Link footer looks like this:
+
+....
+    Link: https://gerrit-review.googlesource.com/id/Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
+....
+
+The advantage of this style of footer is that it usually acts
+as a link directly to the change's review page, provided that
+the change has been uploaded to Gerrit. Projects such as the
+link:https://www.kernel.org/doc/html/latest/maintainer/configure-git.html#creating-commit-links-to-lore-kernel-org[Linux kernel]
+have a convention of adding links to commit messages using the
+Link footer.
+
+If multiple changes have been uploaded to Gerrit with the same
+change ID, for example if a change has been cherry-picked to multiple
+branches, the link will take the user to a list of changes.
+
+The base URL in the footer is required to match the server's base URL.
+If the URL does not match, the server will not recognize the footer
+as a change ID footer.
+
+The link:cmd-hook-commit-msg.html[commit-msg hook] can be configured
+to insert Link footers instead of Change-Id footers by setting the
+property `gerrit.reviewUrl` to the base URL of the Gerrit server.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index a050021..1a9ffdb 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -256,8 +256,8 @@
 if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
 the password in the request is first checked against the HTTP password and, if
 it does not match, it is then validated against the LDAP password.
-Service users that only exist in the Gerrit database are authenticated by their
-HTTP passwords.
+Service users that are link:cmd-create-account.html[internal-only] are
+authenticated by their HTTP passwords.
 
 * `LDAP_BIND`
 +
@@ -611,7 +611,7 @@
 single call would trigger a full LDAP authentication and groups resolution
 which could introduce a noticeable latency on the overall execution
 and produce unwanted load to the LDAP server.
-+
+
 [[auth.gitOAuthProvider]]auth.gitOAuthProvider::
 +
 Selects the OAuth 2 provider to authenticate git over HTTP traffic with.
@@ -677,8 +677,10 @@
 [[auth.autoUpdateAccountActiveStatus]]auth.autoUpdateAccountActiveStatus::
 +
 Whether to allow automatic synchronization of an account's inactive flag upon login.
++
 If set to true, upon login, if the authentication back-end reports the account as active,
-the account's inactive flag in the internal Gerrit database will be updated to be active.
+the account's inactive flag in NoteDb will be updated to be active.
++
 If the authentication back-end reports the account as inactive, the account's flag will be
 updated to be inactive and the login attempt will be blocked. Users enabling this feature
 should ensure that their authentication back-end is supported. Currently, only
@@ -693,8 +695,11 @@
 
 [[auth.skipFullRefEvaluationIfAllRefsAreVisible]]auth.skipFullRefEvaluationIfAllRefsAreVisible::
 +
-Whether to skip the full ref visibility checks as a performance shortcut when all refs are
-visible to a user. Full ref filtering would filter out things like pending edits.
+Whether to skip the full ref visibility checks as a performance shortcut when a
+user has READ permission for all refs.
++
+The full ref filtering would filter out refs for pending edits, private changes
+and auto merge commits.
 +
 By default, true.
 
@@ -764,6 +769,24 @@
 +
 Default is false.
 
+[[cache.openFiles]]cache.openFiles::
++
+The number of file descriptors to add to the limit set by the Gerrit daemon.
++
+Persistent caches are stored on the file system and as such participate in the
+file descriptors utilization. The number of file descriptors can vary depending
+on the cache configuration and the specific backend used.
++
+The additional file descriptors required by the cache should be accounted for
+via this setting, so that the Gerrit daemon can adjust the ulimit accordingly.
++
+If you increase this to a larger setting you may need to also adjust
+the ulimit on file descriptors for the host JVM, as Gerrit needs
+additional file descriptors available for network sockets and other
+repository data manipulation.
++
+Default is 0.
+
 [[cache.name.maxAge]]cache.<name>.maxAge::
 +
 Maximum age to keep an entry in the cache. Entries are removed from
@@ -846,10 +869,54 @@
 * `"change_notes"`: disk storage is disabled by default
 * `"diff_summary"`: default is `1g` (1 GiB of disk space)
 * `"external_ids_map"`: disk storage is disabled by default
+* `"persisted_projects"`: default is `1g` (1 GiB of disk space)
 
 +
 If 0 or negative, disk storage for the cache is disabled.
 
+[[cache.name.expireAfterWrite]]cache.<name>.expireAfterWrite::
++
+Duration after which a cached value will be evicted and not
+read anymore.
++
+Values should use common unit suffixes to express their setting:
++
+* ms, milliseconds
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
++
+Disabled by default.
+
+[[cache.name.refreshAfterWrite]]cache.<name>.refreshAfterWrite::
++
+Duration after which we asynchronously refresh the cached value.
++
+Values should use common unit suffixes to express their setting:
++
+* ms, milliseconds
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
++
+This applies only to these caches that support refreshing:
++
+* `"projects"`: Caching project information in-memory. Defaults to 15 minutes.
+
+[[cache.refreshThreadPoolSize]]cache.refreshThreadPoolSize::
++
+Number of threads that are available to refresh cached values that became
+out of date. This applies only to these caches that support refreshing:
++
+* `"projects"`: Caching project information in-memory
++
+Refreshes will only be scheduled on this executor if the values are
+out of sync.
+The check if they are is cheap and always happens on the thread that
+inquires for a cached value.
++
+Defaults to 2.
+
 ==== [[cache_names]]Standard Caches
 
 cache `"accounts"`::
@@ -886,8 +953,8 @@
 this cache should be disabled in a cluster setup using multiple primary
 or multiple replica nodes.
 +
-The cache should be flushed whenever the database changes table is modified
-outside of Gerrit.
+The cache should be flushed whenever NoteDb change metadata in a repository is
+modified outside of Gerrit.
 
 cache `"diff"`::
 +
@@ -1044,10 +1111,17 @@
 has been converted from Markdown to HTML. The memoryLimit refers to
 the bytes of memory dedicated to storing the documentation.
 
+cache `"persisted_projects"`::
++
+Caches the project description records, from the `refs/meta/config`
+branch of each project. This is the persisted variant of the
+`projects` cache. The intention is for this cache to have an in-memory
+size of 0.
+
 cache `"projects"`::
 +
-Caches the project description records, from the `projects` table
-in the database.  If a project record is updated or deleted, this
+Caches the project description records, from the `refs/meta/config`
+branch of each project. If a project record is updated or deleted, this
 cache should be flushed.  Newly inserted projects do not require
 a cache flush, as they will be read upon first reference.
 
@@ -1147,22 +1221,6 @@
 +
 Default is true, enabled.
 
-[[cache.projects.checkFrequency]]cache.projects.checkFrequency::
-+
-How often project configuration should be checked for update from Git.
-Gerrit Code Review caches project access rules and configuration in
-memory, checking the refs/meta/config branch every checkFrequency
-minutes to see if a new revision should be loaded and used for future
-access. Values can be specified using standard time unit abbreviations
-('ms', 'sec', 'min', etc.).
-+
-If set to 0, checks occur every time, which may slow down operations.
-If set to 'disabled' or 'off', no check will ever be done.
-Administrators may force the cache to flush with
-link:cmd-flush-caches.html[gerrit flush-caches].
-+
-Default is 5 minutes.
-
 [[cache.projects.loadOnStartup]]cache.projects.loadOnStartup::
 +
 If the project cache should be loaded during server startup.
@@ -1214,7 +1272,7 @@
 Whether the first user that logs in to the Gerrit server should
 automatically be added to the administrator group and hence get the
 `administrateServer` capability assigned. This is useful to bootstrap
-the authentication database.
+the link:config-accounts.html[account data].
 +
 Default is true.
 
@@ -1271,14 +1329,14 @@
 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.
+The default is true.
 
 [[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.
+The default is false.
 
 [[change.largeChange]]change.largeChange::
 +
@@ -1394,6 +1452,14 @@
 +
 The default limit is 1MiB.
 
+[[change.sendNewPatchsetEmails]]change.sendNewPatchsetEmails::
++
+When false, emails will not be sent to owners, reviewers, and cc for
+creating a new patchset unless they are project watchers or have starred
+the change.
++
+Default is true.
+
 [[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
 +
 Show assignee field in changes table. If set to false, assignees will
@@ -2088,7 +2154,7 @@
 or "http://example.com:8080/gerrit/" so Gerrit can output links that point
 back to itself.
 +
-Setting this is highly recommended, as its necessary for the upload
+Setting this is highly recommended, as it is necessary for the upload
 code invoked by "git push" or "repo upload" to output hyperlinks
 to the newly uploaded changes.
 
@@ -2149,10 +2215,20 @@
 by the system administrator, and might not even be running on the
 same host as Gerrit.
 
+[[gerrit.installBatchModule]]gerrit.installBatchModule::
++
+Repeatable list of class name of additional Guice modules to load as
+override to the batchInjector's modules during the init phases.
+Classes are resolved using the primary Gerrit class loader, hence the
+class needs to be either declared in Gerrit or an additional JAR
+located under the `/lib` directory.
++
+By default unset.
+
 [[gerrit.installDbModule]]gerrit.installDbModule::
 +
 Repeatable list of class name of additional Guice modules to load at
-Gerrit startup as part of the dbInjector and during the init phases.
+Gerrit startup as part of the dbInjector.
 Classes are resolved using the primary Gerrit class loader, hence the
 class needs to be either declared in Gerrit or an additional JAR
 located under the `/lib` directory.
@@ -2162,7 +2238,7 @@
 [[gerrit.installModule]]gerrit.installModule::
 +
 Repeatable list of class name of additional Guice modules to load at
-Gerrit startup as part of the sysInjector and during the init phases.
+Gerrit startup as part of the sysInjector.
 Classes are resolved using the primary Gerrit class loader, hence the
 class needs to be either declared in Gerrit or an additional JAR
 located under the `/lib` directory.
@@ -2175,6 +2251,7 @@
   installModule = com.googlesource.gerrit.libmodule.MyModule
   installModule = com.example.abc.OurSpecialSauceModule
   installDbModule = com.example.def.OurCustomProvider
+  installBatchModule = com.example.ghi.CustomBatchInitModule
 ----
 
 [[gerrit.listProjectsFromIndex]]gerrit.listProjectsFromIndex::
@@ -2290,8 +2367,8 @@
 [[gerrit.experimentalRollingUpgrade]]gerrit.experimentalRollingUpgrade::
 +
 Enable Gerrit rolling upgrade to the next version.
-For example if Gerrit v3.1 is version N (All-Projects:refs/meta/version=181)
-then its next version N+1 is v3.2 (All-Projects:refs/meta/version=183).
+For example if Gerrit v3.2 is version N (All-Projects:refs/meta/version=183)
+then its next version N+1 is v3.3 (All-Projects:refs/meta/version=184).
 Allow Gerrit to start even if the underlying schema version has been bumped to
 the next Gerrit version.
 +
@@ -2308,7 +2385,7 @@
 1. Set gerrit.experimentalRollingUpgrade to true on all Gerrit masters
 2. Set the first master unhealthy
 3. Shutdown the first master and [upgrade](install.html#init) to the next version
-4. Startup the first master, wait for the online reindex to complete
+4. Startup the first master, wait for the online reindex to complete (where applicable)
 5. Verify the the first master upgrade is successful and online reindex is complete
 6. Set the first master healthy
 7. Repeat steps 2. to 6. for all the other Gerrit nodes
@@ -3328,6 +3405,49 @@
 config is backwards compatible with what the default was before the config
 was added.
 
+[[event.comment-added.publishPatchSetLevelComment]]event.comment-added.publishPatchSetLevelComment::
++
+Add patch set level comment as event comment. Without this option, patch set
+level comment will not be included in the event comment attribute. Given that
+currently patch set level, file and robot comments are not exposed in the
+`comment-added` event type, those comments will be lost. One particular use
+case is to re-trigger CI build from the change screen by adding a comment with
+specific content, e.g.: `recheck`. Jenkins Gerrit Trigger plugin and Zuul CI
+depend on this feature to trigger change verification.
++
+By default, true.
+
+[[experiments]]
+=== Section experiments
+
+This section covers experimental new features. Gerrit's frontend uses experiments
+to research new behavior. Once the research is done, the experimental feature
+either stays and the experimentation flag gets removed, or the feature as a whole
+gets removed
+
+[[experiments.enabled]]experiments.enabled::
++
+List of experiments that are currently enabled. The release notes contain currently
+available experiments.
++
+We will not remove experiments in stable patch releases. They are likely to be
+removed in the next stable version.
+
+----
+[experiments]
+  enabled = ExperimentKey
+----
+
+[[experiments.disabled]]experiments.disabled::
++
+List of experiments that are currently disabled. The release notes contain currently
+available experiments. This list disables experiments with the given key that are
+either enabled by default or explicitly in the config.
+
+----
+[experiments]
+  disabled = ExperimentKey
+----
 
 [[ldap]]
 === Section ldap
@@ -3379,6 +3499,11 @@
 If `auth.type` is `LDAP` this setting should use `ldaps://` to
 ensure the end user's plaintext password is transmitted only over
 an encrypted connection.
++
+If you want to configure multiple ldap servers you can try to put
+multiple ldap urls separated by a space:
+`server = ldaps://ldap1 ldaps://ldap2`
+See https://bugs.chromium.org/p/gerrit/issues/detail?id=10841[issue 10841].
 
 [[ldap.startTls]]ldap.startTls::
 +
@@ -3991,18 +4116,6 @@
 +
 Default is 5 minutes.
 
-[[receive.changeUpdateThreads]]receive.changeUpdateThreads::
-+
-Number of threads to perform change creation or patch set updates
-concurrently. Each thread uses its own database connection from
-the database connection pool, and if all threads are busy then
-main receive thread will also perform a change creation or patch
-set update.
-+
-Defaults to 1, using only the main receive thread. This feature is for
-databases with very high latency that can benefit from concurrent
-operations when multiple changes are impacted at once.
-
 [[receive.checkMagicRefs]]receive.checkMagicRefs::
 +
 If true, Gerrit will verify the destination repository has
@@ -4224,9 +4337,8 @@
 
 [[repository.name.ownerGroup]]repository.<name>.ownerGroup::
 +
-A name of a group which exists in the database. Zero, one or many
-groups are allowed.  Each on its own line.  Groups which don't exist
-in the database are ignored.
+A name of a link:config-groups.html[group] which exists. Zero, one or many
+groups are allowed.  Each on its own line.  Groups which don't exist are ignored.
 
 [[retry]]
 === Section retry
@@ -4410,19 +4522,25 @@
 
 [[receiveemail.filter.mode]]receiveemail.filter.mode::
 +
-A black- and whitelist filter to filter incoming emails.
+An allow and block filter to filter incoming emails.
 +
 If `OFF`, emails are not filtered by the list filter.
 +
-If `WHITELIST`, only emails where a pattern from
+If `ALLOW`, only emails where a pattern from
 <<receiveemail.filter.patterns,receiveemail.filter.patterns>>
 matches 'From' will be processed.
 +
-If `BLACKLIST`, only emails where no pattern from
+If `BLOCK`, only emails where no pattern from
 <<receiveemail.filter.patterns,receiveemail.filter.patterns>>
 matches 'From' will be processed.
 +
 Defaults to `OFF`.
++
+The previous filter-names 'BLACKLIST' and 'WHITELIST' have been deprecated
+since they may be considered disrespectful and there's no technical or
+practical reason to use these exact terms for the filters.
+For backwards compatibility they are still supported but support for these
+deprecated terms will be removed in future releases.
 
 [[receiveemail.filter.patterns]]receiveemail.filter.patterns::
 +
@@ -4553,9 +4671,10 @@
 
 [[sendemail.allowrcpt]]sendemail.allowrcpt::
 +
-If present, each value adds one entry to the whitelist of email
-addresses that Gerrit can send email to.  If set to a complete
-email address, that one address is added to the white list.
+If present, each value adds one entry to the list of allowed email
+addresses that Gerrit can send emails to.  If set to a complete
+email address, that one address is added to the list of allowed
+emails.
 If set to a domain name, any address at that domain can receive
 email from Gerrit.
 +
@@ -4566,9 +4685,10 @@
 
 [[sendemail.denyrcpt]]sendemail.denyrcpt::
 +
-If present, each value adds one entry to the blacklist of email
-addresses that Gerrit can send email to.  If set to a complete
-email address, that one address is added to the blacklist.
+If present, each value adds one entry to the list of email
+addresses that Gerrit can't send emails to.  If set to a complete
+email address, that one address is added to the list of blocked
+emails.
 If set to a domain name, any address at that domain can *not* receive
 email from Gerrit.
 +
@@ -4767,16 +4887,16 @@
 [[sshd.batchThreads]]sshd.batchThreads::
 +
 Number of threads to allocate for SSH command requests from
-link:access-control.html#non-interactive_users[non-interactive users].
+link:access-control.html#service_users[service users].
 If equals to 0, then all non-interactive requests are executed in the same
 queue as interactive requests.
 +
 Any other value will remove the number of threads from the queue
 allocated to interactive users, and create a separate thread pool
 of the requested size, which will be used to run commands from
-non-interactive users.
+service users.
 +
-If the number of threads requested for non-interactive users is larger
+If the number of threads requested for service users is larger
 than the total number of threads allocated in sshd.threads, then the
 value of sshd.threads is increased to accommodate the requested value.
 +
@@ -5042,6 +5162,7 @@
 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
@@ -5054,6 +5175,13 @@
 +
 By default 50.
 
+[[suggest.skipServiceUsers]]suggest.skipServiceUsers::
++
+If link:access-control.html#service_users[service users] should be skipped when
+suggesting reviewers.
++
+By default true.
+
 [[tracing]]
 === Section tracing
 
@@ -5434,10 +5562,6 @@
 [auth]
   registerEmailPrivateKey = 2zHNrXE2bsoylzUqDxZp0H1cqUmjgWb6
 
-[database]
-  username = webuser
-  password = s3kr3t
-
 [ldap]
   password = l3tm3srch
 
@@ -5557,10 +5681,12 @@
 
 [[receive.autogc]]receive.autogc::
 +
-By default, `git-receive-pack` will run auto gc after receiving data from git-push and updating refs.
+By default, up to Gerrit 3.2 `git-receive-pack` will run auto gc after receiving data from git-push and updating refs.
 You can stop it by setting this variable to `false`. This is recommended in gerrit to avoid the
 additional load this creates. Instead schedule gc using link:cmd-gc.html#gc.startTime[gc.startTime]
 and link:cmd-gc.html#gc.interval[gc.interval] or e.g. in a cron job that runs gc in a separate process.
+Since Gerrit 3.3 the init command will auto-configure `git-receive-pack = false` in `etc/jgit.config` if
+it wasn't set manually and show a warning if it was set to `true` manually.
 
 GERRIT
 ------
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 5d22c0b..85006dc 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -109,10 +109,10 @@
 reverting a change.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
 ChangeFooter.
 
-=== RegisterNewEmail.soy
+=== RegisterNewEmail.soy and RegisterNewEmailHtml.soy
 
-The `RegisterNewEmail.soy` template will determine the contents of the email
-related to registering new email accounts.
+Those templates will determine the contents of the email related to registering
+new email accounts.
 
 === ReplacePatchSet.soy and ReplacePatchSetHtml.soy
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 564b2a6..5d71b41 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -18,10 +18,10 @@
 To build Gerrit from source, you need:
 
 * A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 8|9|10|11|...
+* A JDK for Java 8|11|...
 * Python 3
 * link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
-* Bower (`sudo npm install -g bower`)
+* Bower (`npm install -g bower`)
 * 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
@@ -32,29 +32,41 @@
 [[bazel]]
 === Bazel
 
-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
-seamlessly uses Bazelisk, which then runs the proper `bazel` binary version.
+link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank] is a version
+manager for link:https://bazel.build/[Bazel,role=external,window=_blank], similar to how `nvm`
+manages `npm` versions. It takes care of downloading and installing Bazel itself, so you don't have
+to worry about using the correct version of Bazel. Bazelisk can be installed in different
+ways: link:https://docs.bazel.build/install-bazelisk.html[Install,role=external,window=_blank]
 
 [[java]]
 === Java
 
-==== MacOS
-
-On MacOS, ensure that "Java for MacOS X 10.5 Update 4" (or higher) is installed
-and that `JAVA_HOME` is set to the
-link:install.html#Requirements[required Java version].
-
-Java installations can typically be found in
-"/System/Library/Frameworks/JavaVM.framework/Versions".
+Ensure that the link:install.html#Requirements[required Java version]
+is installed and that `JAVA_HOME` is set to it.
 
 To check the installed version of Java, open a terminal window and run:
 
 `java -version`
 
+[[java-8]]
+==== Java 8 support (deprecated)
+
+Java 8 is a legacy Java release and support for Java 8 will be discontinued
+in future gerrit releases. To build Gerrit with Java 8 language level, run:
+
+```
+  $ bazel build :release
+```
+
+[[java-11]]
+==== Java 11 support
+
+To build Gerrit with Java 11 language level, run:
+
+```
+  $ bazel build --java_toolchain=//tools:error_prone_warnings_toolchain_java11 :release
+```
+
 [[java-13]]
 ==== Java 13 support
 
@@ -102,22 +114,6 @@
 Now, invoking Bazel with just `bazel build :release` would include
 all those options.
 
-[[java-11]]
-==== Java 11 support
-
-Java 11 is supported through alternative java toolchain
-link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
-To build Gerrit with Java 11, specify JDK 11 java toolchain:
-
-```
-  $ bazel build \
-      --host_javabase=@bazel_tools//tools/jdk:remote_jdk11 \
-      --javabase=@bazel_tools//tools/jdk:remote_jdk11 \
-      --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
-      --java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
-      :release
-```
-
 === 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].
 
@@ -272,7 +268,15 @@
   bazel-bin/Documentation/searchfree.zip
 ----
 
-To build the executable WAR with the documentation included:
+To generate HTML files skipping the zip archiving:
+
+----
+  bazel build Documentation
+----
+
+And open `bazel-bin/Documentation/index.html`.
+
+To build the Gerrit executable WAR with the documentation included:
 
 ----
   bazel build withdocs
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 23ecd67..477641b 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -144,7 +144,7 @@
   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
+* Within 30 calendar days the contributor should hear back from the
   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.
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
index 04e2420..6b777d3 100644
--- a/Documentation/dev-core-plugins.txt
+++ b/Documentation/dev-core-plugins.txt
@@ -155,6 +155,52 @@
    link:https://www.gerritcodereview.com/news.html[project news].
 --
 
+[[removing]]
+=== Removing Core Plugins
+
+A core plugin could be subject to NOT be considered core anymore if:
+
+1. Does not respect the license:
++
+The plugin code or the libraries used are not following anymore the
+Apache License Version 2.0.
+
+2. Is out of scope:
++
+The plugin functionality has gone outside the Gerrit-related scope,
+has a clear scope or conflict with other core plugins or existing and
+planned Gerrit core features.
++
+NOTE: The plugin would need to remain core until the planned replacement gets
+implemented. Otherwise the feature is likely missing between the removal and
+planned implementation times.
+
+3. Is not relevant:
++
+The plugin functionality is no more relevant to a majority of the Gerrit community:
++
+--
+** An out of the box Gerrit installation won’t be missing anything if the plugin is
+   not installed.
+** It isn’t anymore used by most sites.
+** Multiple parties (different organizations/companies) have abandoned the use of
+   the plugin and agree that it should not be anymore a core plugin.
+** If the same or similar functionality is provided by multiple plugins, the plugin
+   is not a clear recommended solution anymore by the community.
+** Whether a plugin is no more 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 engineering
+   steering committee to make a decision.
+--
+
+4. Degraded code quality:
++
+The plugin code maintenance is lacking and has not anymore good test coverage.
+Maintaining the plugin code creates a significant overhead for the Gerrit maintainers.
+
+5. Outdated documentation:
++
+The plugin functionality documented is significantly outdated.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
index 7e48eea..6dc6f5f 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -132,7 +132,7 @@
 
 For proposed features the contributor should hear back from the
 link:dev-processes.html#steering-committee[engineering steering
-committee] within 14 calendar days whether the proposed feature is in
+committee] within 30 calendar days whether the proposed feature is in
 scope of the project and if it can be accepted.
 
 [[meetings]]
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 742cf42..bbe227a 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -4,7 +4,8 @@
 This document is about configuring Gerrit Code Review into an
 Eclipse workspace for development.
 
-Java 8 or later SDK is required.
+Java 11 or later SDK is required.
+Otherwise, java 8 can still be used for now as described below.
 
 [[setup]]
 == Project Setup
@@ -30,6 +31,10 @@
 ----
 
 First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
+If running Eclipse on Java 8, add the extra parameter
+`-e='--java_toolchain=//tools:error_prone_warnings_toolchain'`
+for generating a compatible project.
+
 Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
 
@@ -79,15 +84,16 @@
 link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
 and run `tools/eclipse/project.py`.
 
-[[Newer Java versions]]
+== Java Versions
 
-Java 9 and later are supported, but some adjustments must be done, because
-Java 8 is still the default:
+Java 11 is supported as a default, but some adjustments must be done for other JDKs:
 
 * Add JRE, e.g.: directory: /usr/lib64/jvm/java-9-openjdk, name: java-9-openjdk-9
 * Change execution environment for gerrit project to: JavaSE-9 (java-9-openjdk-9)
 * Check that compiler compliance level in gerrit project is set to: 9
 
+Moreover, the actual java 11 language features are not supported yet.
+
 [[Formatting]]
 == Code Formatter Settings
 
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index b67d546..149b14a 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -9,7 +9,7 @@
 <<dev-bazel#installation,Building with Bazel - Installation>>.
 
 It's strongly recommended to verify you can build your Gerrit tree with Bazel
-for Java 8 from the command line first. Ensure that at least
+for Java 11 from the command line first. Ensure that at least
 `bazel build gerrit` runs successfully before you proceed.
 
 === IntelliJ version and Bazel plugin
@@ -21,12 +21,12 @@
 Also note that the version of the Bazel plugin used in turn may or may not be
 compatible with the Bazel version used.
 
-In addition, Java 8 must be specified on your path or via `JAVA_HOME` so that
+In addition, Java 11 must be specified on your path or via `JAVA_HOME` so that
 building with Bazel via the Bazel plugin is possible.
 
 TIP: If the synchronization of the project with the BUILD files using the Bazel
 plugin fails and IntelliJ reports the error **Could not get Bazel roots**, this
-indicates that the Bazel plugin couldn't find Java 8.
+indicates that the Bazel plugin couldn't find Java 11.
 
 === Installation of IntelliJ IDEA
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 9a5ce03..03e8ce6 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -379,13 +379,13 @@
 notifications of these events by implementing the corresponding
 listeners.
 
-* `com.google.gerrit.common.EventListener`:
+* `com.google.gerrit.server.events.EventListener`:
 +
 Allows to listen to events without user visibility restrictions. These
 are the same link:cmd-stream-events.html#events[events] that are also streamed by
 the link:cmd-stream-events.html[gerrit stream-events] command.
 
-* `com.google.gerrit.common.UserScopedEventListener`:
+* `com.google.gerrit.server.events.UserScopedEventListener`:
 +
 Allows to listen to events visible to the specified user. These are the
 same link:cmd-stream-events.html#events[events] that are also streamed
@@ -745,7 +745,8 @@
 Plugin methods implementing search operands (returning a
 `Predicate<ChangeData>`), must be defined on a class implementing
 one of the `ChangeQueryBuilder.ChangeOperandsFactory` interfaces
-(.e.g., ChangeQueryBuilder.ChangeHasOperandFactory).  The specific
+(.e.g., ChangeQueryBuilder.ChangeHasOperandFactory or
+ChangeQueryBuilder.ChangeIsOperandFactory).  The specific
 `ChangeOperandFactory` class must also be bound to the `DynamicSet` from
 a module's `configure()` method in the plugin.
 
@@ -926,13 +927,15 @@
 [[query_attributes]]
 == Change Attributes
 
+=== ChangePluginDefinedInfoFactory
+
 Plugins can provide additional attributes to be returned from the Get Change and
-Query Change APIs by implementing implementing the `ChangeAttributeFactory`
-interface and adding it to the `DynamicSet` in the plugin module's `configure()`
-method. The new attribute(s) will be output under a `plugin` attribute in the
-change output. This can be further controlled by registering a class containing
-@Option declarations as a `DynamicBean`, annotated with the with HTTP/SSH
-commands on which the options should be available.
+Query Change APIs by implementing the `ChangePluginDefinedInfoFactory` interface
+and adding it to the `DynamicSet` in the plugin module's `configure()` method.
+The new attribute(s) will be output under a `plugin` attribute in the change
+output. This can be further controlled by registering a class containing @Option
+declarations as a `DynamicBean`, annotated with the HTTP/SSH commands on
+which the options should be available.
 
 The example below shows a plugin that adds two attributes (`exampleName` and
 `changeValue`), to the change query output, when the query command is provided
@@ -944,7 +947,7 @@
   @Override
   protected void configure() {
     // Register attribute factory.
-    DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+    DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
         .to(AttributeFactory.class);
 
     // Register options for GET /changes/X/change and /changes/X/detail.
@@ -969,7 +972,7 @@
   public boolean all = false;
 }
 
-public class AttributeFactory implements ChangeAttributeFactory {
+public class AttributeFactory implements ChangePluginDefinedInfoFactory {
   protected MyChangeOptions options;
 
   public class PluginAttribute extends PluginDefinedInfo {
@@ -983,14 +986,17 @@
   }
 
   @Override
-  public PluginDefinedInfo create(ChangeData c, BeanProvider bp, String plugin) {
+  public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds, BeanProvider bp, String plugin) {
     if (options == null) {
       options = (MyChangeOptions) bp.getDynamicBean(plugin);
     }
+    Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
     if (options.all) {
-      return new PluginAttribute(c);
+      cds.forEach(cd -> out.put(cd.getId(), new PluginAttribute(cd)));
+      return out;
     }
-    return null;
+    return ImmutableMap.of();
   }
 }
 ----
@@ -1025,10 +1031,20 @@
 }
 ----
 
-Implementors of the `ChangeAttributeFactory` interface should check whether
-they need to contribute to the link:#change-etag-computation[change ETag
-computation] to prevent callers using ETags from potentially seeing outdated
-plugin attributes.
+Runtime exceptions generated by the implementors of ChangePluginDefinedInfoFactory
+are encapsulated in PluginDefinedInfo objects which are part of SSH/REST query output.
+
+=== ChangeAttributeFactory
+
+Alternatively, there is also `ChangeAttributeFactory` which takes in one single
+`ChangeData` at a time. `ChangePluginDefinedInfoFactory` should be preferred
+over this as it handles many changes at once which also decreases the round-trip
+time for queries resulting in performance increase for bulk queries.
+
+Implementors of the `ChangePluginDefinedInfoFactory` and `ChangeAttributeFactory`
+interfaces should check whether they need to contribute to the
+link:#change-etag-computation[change ETag computation] to prevent callers using
+ETags from potentially seeing outdated plugin attributes.
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
@@ -2705,7 +2721,7 @@
 Plugins may also decide not to vote on a given change by returning an
 `Optional.empty()` (ie: the plugin is not enabled for this repository).
 
-If a plugin decides not to vote, it's name will not be displayed in the UI and
+If a plugin decides not to vote, its name will not be displayed in the UI and
 it will not be recoded in the database.
 
 .Gerrit's Pre-submit handling with three plugins
@@ -2739,8 +2755,8 @@
 [source, java]
 ----
 import java.util.Optional;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRecord.Status;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 0fe2372..5049831 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -15,7 +15,7 @@
 * ensuring timely design reviews
 * ensuring that new features are compatible with the project vision and
   are well aligned with other features (give feedback on new
-  link:dev-design-docs.html[design docs] within 14 calendar days)
+  link:dev-design-docs.html[design docs] within 30 calendar days)
 * approving/rejecting link:dev-design-docs.html[designs], vetoing new
   features
 * assigning link:dev-roles.html#mentor[mentors] for approved features
@@ -278,14 +278,32 @@
 test that verifies that the security vulnerability is no longer present.
 +
 Review and approval of the security fixes must be done by the Gerrit
-maintainers. Verifications must be done manually since the Gerrit CI doesn't
-build and test changes of the `gerrit-security-fixes` repository (and it
-shouldn't because everything on the CI server is public which would break
-the embargo).
+maintainers.
 +
 Once a security fix is ready and submitted, it should be cherry-picked to all
 branches that should be fixed.
 
+. CI validation of the security fix:
++
+The validation of the security fixes does not happen on the regular Gerrit CI,
+because it would compromise the confidentiality of the fix and therefore break
+the embargo.
++
+The release manager maintains a private branch on the
+link:https://gerrit-review.googlesource.com/admin/repos/gerrit-ci-scripts[gerrit-ci-scripts,role=external,window=_blank] repository
+which contains a special build pipeline with special visibility restrictions.
++
+The validation process provides feedback, in terms of Code-Style, Verification
+and Checks, to the incoming security changes. The links associated
+with the build logs are exposed over the Internet but their access limited
+to only those who are actively participating in the development and review of
+the security fix.
++
+The maintainers that are willing to access the links to the CI logs need
+to request a time-limited (maximum 30 days) nominal X.509 certificate from a
+CI maintainer, which allows to access the build logs and analyze failures.
+The release manager may help obtaining that certificate from CI maintainers.
+
 . Creation of fixed releases and announcement of the security vulnerability:
 +
 A release manager should create new bug fix releases for all fixed branches.
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index a4ccccf..db08da5 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -44,15 +44,13 @@
 +
 Generate and publish a PGP key as described in
 link:http://central.sonatype.org/pages/working-with-pgp-signatures.html[
-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].
+Working with PGP Signatures,role=external,window=_blank].
 +
 Please be aware that after publishing your public key it may take a
 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,role=external,window=_blank]
+link:https://gerrit.googlesource.com/homepage/+/master/pages/site/releases/public-keys.md[key list,role=external,window=_blank]
 on the homepage.
 +
 The PGP passphrase can be put in `~/.m2/settings.xml`:
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index 2ca7f22..cecaedc 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -213,10 +213,10 @@
 [[maintainer-election]]
 Maintainers can nominate new maintainers by posting a nomination on the
 non-public maintainers mailing list. Nominations should stay open for
-at least 14 calendar days so that all maintainers have a chance to
+at least 10 calendar days so that all maintainers have a chance to
 vote. To be approved as maintainer a minimum of 5 positive votes and no
 negative votes is required. This means if 5 positive votes without
-negative votes have been reached and 14 calendar days have passed, any
+negative votes have been reached and 10 calendar days have passed, any
 maintainer can close the vote and welcome the new maintainer. Extending
 the voting period during holiday season or if there are not enough
 votes is possible, but the voting period should not exceed 1 month. If
diff --git a/Documentation/images/user-attention-set-dashboard-empty.png b/Documentation/images/user-attention-set-dashboard-empty.png
new file mode 100644
index 0000000..7b15fa0
--- /dev/null
+++ b/Documentation/images/user-attention-set-dashboard-empty.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-dashboard.png b/Documentation/images/user-attention-set-dashboard.png
new file mode 100644
index 0000000..4533380
--- /dev/null
+++ b/Documentation/images/user-attention-set-dashboard.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-hovercard.png b/Documentation/images/user-attention-set-hovercard.png
new file mode 100644
index 0000000..8d6af58
--- /dev/null
+++ b/Documentation/images/user-attention-set-hovercard.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-icon-click.png b/Documentation/images/user-attention-set-icon-click.png
new file mode 100644
index 0000000..32b1961
--- /dev/null
+++ b/Documentation/images/user-attention-set-icon-click.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-icon.png b/Documentation/images/user-attention-set-icon.png
new file mode 100644
index 0000000..a6789b9
--- /dev/null
+++ b/Documentation/images/user-attention-set-icon.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-modify.png b/Documentation/images/user-attention-set-reply-modify.png
new file mode 100644
index 0000000..a8895f9
--- /dev/null
+++ b/Documentation/images/user-attention-set-reply-modify.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-select.png b/Documentation/images/user-attention-set-reply-select.png
new file mode 100644
index 0000000..e93ff58
--- /dev/null
+++ b/Documentation/images/user-attention-set-reply-select.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-user-prefs.png b/Documentation/images/user-attention-set-user-prefs.png
new file mode 100644
index 0000000..47cdbf5
--- /dev/null
+++ b/Documentation/images/user-attention-set-user-prefs.png
Binary files differ
diff --git a/Documentation/install.txt b/Documentation/install.txt
index 94a576c..6e1a9bd 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -10,39 +10,6 @@
 +
 Gerrit is not yet compatible with Java 13 or newer at this time.
 
-[[cryptography]]
-== Configure Java for Strong Cryptography
-
-Support for extra strength cryptographic ciphers: _AES128CTR_, _AES256CTR_,
-_ARCFOUR256_, and _ARCFOUR128_ can be enabled by downloading the _Java
-Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files_
-from Oracle and installing them into your JRE.
-
-[NOTE]
-Installing JCE extensions is optional and export restrictions may apply.
-
-. 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,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:
-+
-[cols="2"]
-|===
-|README.txt
-|Information about JCE and installation guide
-
-|local_policy.jar
-|Unlimited strength local policy file
-
-|US_export_policy.jar
-|Unlimited strength US export policy file
-|===
-. Install the unlimited strength policy JAR files by following instructions
-found in `README.txt`.
-
 [[download]]
 == Download Gerrit
 
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index 92732d0..6565ba4 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -296,6 +296,10 @@
 * Review the link:intro-project-owner.html[Project Owners guide] to learn more
   about configuring projects in Gerrit, including setting user permissions and
   configuring verification checks
+* Read through the Git and Gerrit training slides that explain concepts and
+  workflows in detail. They are meant for self-studying how Git and Gerrit work:
+** link:https://docs.google.com/presentation/d/1IQCRPHEIX-qKo7QFxsD3V62yhyGA9_5YsYXFOiBpgkk/edit?usp=sharing[Git explained: Git Concepts and Workflows]
+** link:https://docs.google.com/presentation/d/1C73UgQdzZDw0gzpaEqIC6SPujZJhqamyqO1XOHjH-uk/edit?usp=sharing[Gerrit explained: Gerrit Concepts and Workflows]
 
 GERRIT
 ------
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 4bc386a..ae494dd 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -247,209 +247,15 @@
 ----
 
 
-[[ba-linkify]]
-ba-linkify
+[[Polymer-2014]]
+Polymer-2014
 
-* ba-linkify
+* @polymer/paper-ripple
+* @polymer/paper-styles
 
-[[ba-linkify_license]]
+[[Polymer-2014_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
-
-* 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.
-
-----
-
-
-[[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.
-
-----
-
-
-[[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.
-
-----
-
-
-[[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.
-
-----
-
-
-[[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.
-
-----
-
-
-[[Polymer-2018]]
-Polymer-2018
-
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
-
-[[Polymer-2018_license]]
-----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+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
@@ -487,250 +293,6 @@
 ----
 
 
-[[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.
-
-----
-
-
-[[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.
-
-----
-
-
 [[Polymer-2015]]
 Polymer-2015
 
@@ -818,6 +380,133 @@
 ----
 
 
+[[Polymer-2017]]
+Polymer-2017
+
+* @polymer/decorators
+* @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.
+
+----
+
+
+[[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.
+
+----
+
+
+[[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.
+
+----
+
+
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -1271,94 +960,99 @@
 ----
 
 
-[[Polymer-2014]]
-Polymer-2014
+[[isarray]]
+isarray
 
-* @polymer/paper-ripple
-* @polymer/paper-styles
+* isarray
 
-[[Polymer-2014_license]]
+[[isarray_license]]
 ----
-Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
+(MIT)
 
-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) 2013 Julian Gruber <julian@juliangruber.com>;
 
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
+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:
 
-   * 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.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
 
-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.
+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-2017]]
-Polymer-2017
+[[page]]
+page
 
-* @polymer/polymer
-* @webcomponents/shadycss
+* page
 
-[[Polymer-2017_license]]
+[[page_license]]
 ----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
+(The MIT License)
 
-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) 2012 TJ Holowaychuk <tj@vision-media.ca>
 
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
+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:
 
-   * 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.
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
 
-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.
+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.
+
+----
+
+
+[[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.
 
 ----
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 4a2630a..55ab4a0 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3190,209 +3190,15 @@
 ----
 
 
-[[ba-linkify]]
-ba-linkify
+[[Polymer-2014]]
+Polymer-2014
 
-* ba-linkify
+* @polymer/paper-ripple
+* @polymer/paper-styles
 
-[[ba-linkify_license]]
+[[Polymer-2014_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
-
-* 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.
-
-----
-
-
-[[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.
-
-----
-
-
-[[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.
-
-----
-
-
-[[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.
-
-----
-
-
-[[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.
-
-----
-
-
-[[Polymer-2018]]
-Polymer-2018
-
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
-
-[[Polymer-2018_license]]
-----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+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
@@ -3430,250 +3236,6 @@
 ----
 
 
-[[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.
-
-----
-
-
-[[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.
-
-----
-
-
 [[Polymer-2015]]
 Polymer-2015
 
@@ -3761,6 +3323,133 @@
 ----
 
 
+[[Polymer-2017]]
+Polymer-2017
+
+* @polymer/decorators
+* @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.
+
+----
+
+
+[[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.
+
+----
+
+
+[[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.
+
+----
+
+
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -4214,94 +3903,99 @@
 ----
 
 
-[[Polymer-2014]]
-Polymer-2014
+[[isarray]]
+isarray
 
-* @polymer/paper-ripple
-* @polymer/paper-styles
+* isarray
 
-[[Polymer-2014_license]]
+[[isarray_license]]
 ----
-Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
+(MIT)
 
-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) 2013 Julian Gruber <julian@juliangruber.com>;
 
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
+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:
 
-   * 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.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
 
-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.
+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-2017]]
-Polymer-2017
+[[page]]
+page
 
-* @polymer/polymer
-* @webcomponents/shadycss
+* page
 
-[[Polymer-2017_license]]
+[[page_license]]
 ----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
+(The MIT License)
 
-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) 2012 TJ Holowaychuk <tj@vision-media.ca>
 
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
+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:
 
-   * 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.
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
 
-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.
+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.
+
+----
+
+
+[[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.
 
 ----
 
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 29bb409..e34071f 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -19,8 +19,7 @@
 
 . A Unix-based server, including any Linux flavor, MacOS, or Berkeley Software
     Distribution (BSD).
-. Java SE Runtime Environment version 1.8. Gerrit is not compatible with Java
-    9 or newer yet.
+. Java SE Runtime Environment version 11 and up.
 
 == Download Gerrit
 
diff --git a/Documentation/logs.txt b/Documentation/logs.txt
index a95956c..f072984 100644
--- a/Documentation/logs.txt
+++ b/Documentation/logs.txt
@@ -8,6 +8,12 @@
 at server startup and then daily at 11pm and
 link:config-gerrit.html#log.rotate[rotated] every midnight.
 
+== Time format
+
+For all timestamps the format `[yyyy-MM-dd'T'HH:mm:ss,SSSXXX]` is used.
+This format is both link:https://www.w3.org/TR/NOTE-datetime[ISO 8601] and
+link:https://tools.ietf.org/html/rfc3339[RFC3339] compatible.
+
 == Logs
 
 The following logs can be written.
@@ -32,10 +38,7 @@
 * `username`: the username used by the client for authentication. "-" for
   anonymous requests.
 * `[date:time]`: The date and time stamp of the HTTP request.
-  The time that the request was received. The format is until Gerrit 3.1
-  `[dd/MMM/yyyy:HH:mm:ss.SSS ZZZZ]`. For Gerrit 3.2 or newer
-  link:https://www.w3.org/TR/NOTE-datetime[ISO 8601 format] `[yyyy-MM-dd'T'HH:mm:ss,SSSZ]`
-  is used for all timestamps.
+  The time that the request was received.
 * `request`: The request line from the client is given in double quotes.
 ** the HTTP method used by the client.
 ** the resource the client requested.
@@ -68,10 +71,6 @@
 Log format:
 
 * `[date time]`: The time that the request was received.
-  The format is until Gerrit 3.1 `[yyyy-mm-dd HH:mm:ss.SSS ZZZZ]`.
-  For Gerrit 3.2 or newer
-  link:https://www.w3.org/TR/NOTE-datetime[ISO 8601 format] `[yyyy-MM-dd'T'HH:mm:ss,SSSZ]`
-  is used for all timestamps.
 * `sessionid`: hexadecimal session identifier, all requests of the
   same connection share the same sessionid. Gerrit does not support multiplexing multiple
   sessions on the same connection. Grep the log file using the sessionid as filter to
@@ -140,10 +139,6 @@
 Log format:
 
 * `[date time]`: The time that the request was received.
-  The format is until Gerrit 3.1 `[yyyy-mm-dd HH:mm:ss.SSS ZZZZ]`.
-  For Gerrit 3.2 or newer
-  link:https://www.w3.org/TR/NOTE-datetime[ISO 8601 format] `[yyyy-MM-dd'T'HH:mm:ss,SSSZ]`
-  is used for all timestamps.
 * `[thread name]`: : name of the Java thread executing the request.
 * `level`: log level (ERROR, WARN, INFO, DEBUG).
 * `logger`: name of the logger.
@@ -158,10 +153,6 @@
 Log format:
 
 * `[date time]`: The time that the request was received.
-  The format is until Gerrit 3.1 `[yyyy-mm-dd HH:mm:ss.SSS ZZZZ]`.
-  For Gerrit 3.2 or newer
-  link:https://www.w3.org/TR/NOTE-datetime[ISO 8601 format] `[yyyy-MM-dd'T'HH:mm:ss,SSSZ]`
-  is used for all timestamps.
 * `level`: log level (ERROR, WARN, INFO, DEBUG).
 * `message`: log message.
 
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 5eadc74..74ebe144 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -64,6 +64,7 @@
 * `caches/memory_eviction_count`: Memory eviction count.
 * `caches/disk_cached`: Disk entries used by persistent cache.
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
+* `caches/refresh_count`: The number of refreshes per cache with an indicator if a reload was necessary.
 
 Cache disk metrics are expensive to compute on larger installations and are not
 computed by default. They can be enabled via the
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index a13cbfb..7b436a9 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -192,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,role=external,window=_blank](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/remove-notedb-refs.sh)
+The link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/remove-notedb-refs.sh[remove-notedb-refs.sh,role=external,window=_blank]
 script has been written to automate this process.
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 1ce1d61..91bc476 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -11,34 +11,27 @@
 [[loading]]
 == 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,role=external,window=_blank]
-spec.
+link:js-api.html#_entry_point[Entry point] for the plugin.
 
-* The plugin provides pluginname.html, and can be a standalone file or a static
+* The plugin provides pluginname.js, and can be a standalone file or a static
   asset in a jar as a link:dev-plugins.html#deployment[Web UI plugin].
-* pluginname.html contains a `dom-module` tag with a script that uses
-  `Gerrit.install()`. There should only be single `Gerrit.install()` per file.
-* PolyGerrit imports pluginname.html along with all required resources defined in it
-  (fonts, styles, etc).
-* For standalone plugins, the entry point file is a `pluginname.html` file
+* pluginname.js contains a call to `Gerrit.install()`. There should
+  only be single `Gerrit.install()` per file.
+* PolyGerrit imports pluginname.js.
+* For standalone plugins, the entry point file is a `pluginname.js` file
   located in `gerrit-site/plugins` folder, where `pluginname` is an alphanumeric
   plugin name.
 
 Note: Code examples target modern browsers (Chrome, Firefox, Safari, Edge).
 
-Here's a recommended starter `myplugin.html`:
+Here's a recommended starter `myplugin.js`:
 
-``` html
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      'use strict';
+``` js
+Gerrit.install(plugin => {
+  'use strict';
 
-      // Your code here.
-    });
-  </script>
-</dom-module>
+  // Your code here.
+});
 ```
 
 [[low-level-api-concepts]]
@@ -103,34 +96,31 @@
 === Styling DOM Elements
 
 A plugin may provide Polymer's
-https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[style
+https://polymer-library.polymer-project.org/3.0/docs/devguide/style-shadow-dom[style
 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.
+as a standalone `<dom-module>` defined in the same .js file.
+
+See `samples/theme-plugin.js` for examples.
 
 Note: TODO: Insert link to the full styling API.
 
-``` html
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerStyleModule('change-metadata', 'some-style-module');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="some-style-module">
-  <style>
+``` js
+const styleElement = document.createElement('dom-module');
+styleElement.innerHTML =
+ `<template>
+    <style>
     html {
-      --change-metadata-label-status: {
-        display: none;
-      }
-      --change-metadata-strategy: {
-        display: none;
-      }
+      --primary-text-color: red;
     }
-  </style>
-</dom-module>
+   </style>
+ </template>`;
+
+styleElement.register('some-style-module');
+
+Gerrit.install(plugin => {
+  plugin.registerStyleModule('change-metadata', 'some-style-module');
+});
 ```
 
 [[high-level-api-concepts]]
@@ -152,11 +142,11 @@
 `plugin.attributeHelper(element)`
 
 Alternative for
-link:https://www.polymer-project.org/1.0/docs/devguide/data-binding[Polymer data
+link:https://polymer-library.polymer-project.org/3.0/docs/devguide/data-binding[Polymer data
 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
+See `samples/bind-parameters.js` for examples on both Polymer data bindings
 and `attibuteHelper` usage.
 
 === eventHelper
@@ -253,26 +243,29 @@
 
 Here's the recommended approach that uses Polymer for generating custom elements:
 
-``` html
-<dom-module id="some-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerCustomComponent(
-        'change-view-integration', 'some-ci-module');
-    });
-  </script>
-</dom-module>
+``` js
+class SomeCiModule extends Polymer.Element {
+  static get is() {
+    return "some-ci-module";
+  }
+  static get template() {
+    return Polymer.html`
+      Sample link: <a href="http://some.com/foo">Foo</a>
+    `;
+  }
+}
 
-<dom-module id="some-ci-module">
-  <template>
-    Sample link: <a href="http://some.com/foo">Foo</a>
-  </template>
-  <script>
-    Polymer({is: 'some-ci-module'});
-  </script>
-</dom-module>
+// Register this element
+customElements.define(SomeCiModule.is, SomeCiModule);
+
+// Install the plugin
+Gerrit.install(plugin => {
+  plugin.registerCustomComponent('change-view-integration', 'some-ci-module');
+});
 ```
 
+See `samples/` for more examples.
+
 Here's a minimal example that uses low-level DOM Hooks API for the same purpose:
 
 ``` js
@@ -382,93 +375,4 @@
 Note: TODO
 
 === url
-`plugin.url(opt_path)`
-
-Note: TODO
-
-[[deprecated-api]]
-== Deprecated APIs
-
-Some of the deprecated APIs have limited implementation in PolyGerrit to serve
-as a "stepping stone" to allow gradual migration.
-
-=== install
-`plugin.deprecated.install()`
-
-.Params:
-- none
-
-Replaces plugin APIs with a deprecated version. This allows use of deprecated
-APIs without changing JS code. For example, `onAction` is not available by
-default, and after `plugin.deprecated.install()` it's accessible via
-`self.onAction()`.
-
-=== onAction
-`plugin.deprecated.onAction(type, view_name, callback)`
-
-.Params:
-- `*string* type` Action type.
-- `*string* view_name` REST API action.
-- `*function(actionContext)* callback` Callback invoked on action button click.
-
-Adds a button to the UI with a click callback. Exact button location depends on
-parameters. Callback is triggered with an instance of
-link:#deprecated-action-context[action context].
-
-Support is limited:
-
-- type is either `change` or `revision`.
-
-See link:js-api.html#self_onAction[self.onAction] for more info.
-
-=== panel
-`plugin.deprecated.panel(extensionpoint, callback)`
-
-.Params:
-- `*string* extensionpoint`
-- `*function(screenContext)* callback`
-
-Adds a UI DOM element and triggers a callback with context to allow direct DOM
-access.
-
-Support is limited:
-
-- extensionpoint is one of the following:
- * CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK
- * CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK
-
-See link:js-api.html#self_panel[self.panel] for more info.
-
-=== settingsScreen
-`plugin.deprecated.settingsScreent(path, menu, callback)`
-
-.Params:
-- `*string* path` URL path fragment of the screen for direct link.
-- `*string* menu` Menu item title.
-- `*function(settingsScreenContext)* callback`
-
-Adds a settings menu item and a section in the settings screen that is provided
-to plugin for setup.
-
-See link:js-api.html#self_settingsScreen[self.settingsScreen] for more info.
-
-[[deprecated-action-context]]
-=== Action Context (deprecated)
-Instance of Action Context is passed to `onAction()` callback.
-
-Support is limited:
-
-- `popup()`
-- `hide()`
-- `refresh()`
-- `textfield()`
-- `br()`
-- `msg()`
-- `div()`
-- `button()`
-- `checkbox()`
-- `label()`
-- `prependLabel()`
-- `call()`
-
-See link:js-api.html#ActionContext[Action Context] for more info.
+`plugin.url(opt_path)`
\ No newline at end of file
diff --git a/Documentation/pg-plugin-migration.txt b/Documentation/pg-plugin-migration.txt
index bca4b7a..061c687 100644
--- a/Documentation/pg-plugin-migration.txt
+++ b/Documentation/pg-plugin-migration.txt
@@ -79,9 +79,6 @@
   <script>
     Gerrit.install(plugin => {
         // Setup block, is executed before sampleplugin.js
-
-        // Install deprecated JS APIs (onAction, popup, etc)
-        plugin.deprecated.install();
     });
   </script>
 
@@ -105,8 +102,6 @@
 - `sampleplugin.js` is loaded since it's referenced in `sampleplugin.html`
 - setup script tag code is executed before `sampleplugin.js`
 - cleanup script tag code is executed after `sampleplugin.js`
-- `plugin.deprecated.install()` enables deprecated APIs (onAction(), popup(),
-etc) before `sampleplugin.js` is loaded
 
 This means the plugin instance is shared between .html-based and .js-based
 code. This allows to gradually and incrementally transfer code to the new API.
diff --git a/Documentation/prolog-change-facts.txt b/Documentation/prolog-change-facts.txt
index ac69616..e5b4140 100644
--- a/Documentation/prolog-change-facts.txt
+++ b/Documentation/prolog-change-facts.txt
@@ -54,6 +54,17 @@
 |`commit_stats/3`   |`commit_stats(5,20,50).`
     |Number of files modified, number of insertions and the number of deletions.
 
+|`files/3` |`files(file('modules/jgit', 'A', 'SUBMODULE')).`
+
+           |`files(file('a.txt', 'M', 'REGULAR')).'
+
+    A list of tuples: The first argument is a file name of the current patchset.
+    The second argument is the modification type of this file, with the options being
+    'A' for 'added', 'M' for 'modified', 'D' for 'deleted', 'R' for 'renamed', 'C' for
+    'COPIED' and 'W' for 'rewrite'.
+    The third argument is the type of file, with the options being a submodule file
+    'SUBMODULE' and a non-submodule file being 'REGULAR'.
+
 |`pure_revert/1`     |`pure_revert(1).`
     |link:rest-api-changes.html#get-pure-revert[Pure revert] as integer atom (1 if
         the change is a pure revert, 0 otherwise)
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 21b8c9f..32c30b8 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -951,40 +951,40 @@
 sum_list([], S, S).
 ----
 
-=== Example 14: Master and apprentice
-The master and apprentice example allow you to specify a user (the `master`)
-that must approve all changes done by another user (the `apprentice`).
+=== Example 14: Mentor and Mentee
+The mentor and mentee example allow you to specify a user (the `mentor`)
+that must approve all changes done by another user (the `mentee`).
 
 The code first checks if the commit author is in the apprentice database.
-If the commit is done by an `apprentice`, it will check if there is a `+2`
-review by the associated `master`.
+If the commit is done by a `mentee`, it will check if there is a `+2`
+review by the associated `mentor`.
 
 `rules.pl`
 [source,prolog]
 ----
-% master_apprentice(Master, Apprentice).
-% Extend this with appropriate user-id for your master/apprentice setup.
-master_apprentice(user(1000064), user(1000000)).
+% mentor_mentee(Mentor, Mentee).
+% Extend this with appropriate user-id for your mentor/mentee setup.
+mentor_mentee(user(1000064), user(1000000)).
 
 submit_rule(S) :-
     gerrit:default_submit(In),
     In =.. [submit | Ls],
-    add_apprentice_master(Ls, R),
+    add_mentee_mentor(Ls, R),
     S =.. [submit | R].
 
-check_master_approval(S1, S2, Master) :-
+check_mentor_approval(S1, S2, Mentor) :-
     gerrit:commit_label(label('Code-Review', 2), R),
-    R = Master, !,
-    S2 = [label('Master-Approval', ok(R)) | S1].
-check_master_approval(S1, [label('Master-Approval', need(_)) | S1], _).
+    R = Mentor, !,
+    S2 = [label('Mentor-Approval', ok(R)) | S1].
+check_mentor_approval(S1, [label('Mentor-Approval', need(_)) | S1], _).
 
-add_apprentice_master(S1, S2) :-
+add_mentee_mentor(S1, S2) :-
     gerrit:commit_author(Id),
-    master_apprentice(Master, Id),
+    mentor_mentee(Mentor, Id),
     !,
-    check_master_approval(S1, S2, Master).
+    check_mentor_approval(S1, S2, Mentor).
 
-add_apprentice_master(S, S).
+add_mentee_mentor(S, S).
 ----
 
 === Example 15: Make change submittable if all comments have been resolved
@@ -1083,6 +1083,35 @@
 indicate to the user that the change has to be a pure revert in order
 to become submittable.
 
+=== Example 17: Make a change submittable if it doesn't include specific files
+
+We can block any change which contains a submodule file change:
+
+`rules.pl`
+[source,prolog]
+----
+submit_rule(submit(R)) :-
+  gerrit:includes_file(file(_,_,'SUBMODULE')),
+  !,
+  R = label('All-Submodules-Resolved', need(_)).
+submit_rule(submit(label('All-Submodules-Resolved', ok(A)))) :-
+  gerrit:commit_author(A).
+----
+
+We can also block specific files, modification type, or file type,
+by changing include_files/1 to a different parameter. E.g,
+include_files('a.txt',_,_) includes any update to "a.txt", and
+('a.txt','D',_) includes any deletion to "a.txt". Also, (_,_,_) includes
+any file (other than magic file).
+
+An inclusive list of possible arguments using the code above with variations
+of include_file:
+The first parameter is the file name.
+The second is the modification type ('A' for 'added', 'M' for 'modified',
+'D' for 'deleted', 'R' for 'renamed', 'C' for 'COPIED' and 'W' for 'rewrite').
+The third argument is the type of file, with the options being a submodule
+file 'SUBMODULE' and a non-submodule file being 'REGULAR'.
+
 == Examples - Submit Type
 The following examples show how to implement own submit type rules.
 
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index c2a7d21..6664aa2 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -287,12 +287,12 @@
          "15bfcd8a6de1a69c50b30cedcdcc951c15703152": {
            "url": "#/admin/groups/uuid-15bfcd8a6de1a69c50b30cedcdcc951c15703152",
            "options": {},
-           "description": "Users who perform batch actions on Gerrit",
+           "description": "Service accounts that interact with Gerrit",
            "group_id": 2,
            "owner": "Administrators",
            "owner_id": "53a4f647a89ea57992571187d8025f830625192a",
            "created_on": "2009-06-08 23:31:00.000000000",
-           "name": "Non-Interactive Users"
+           "name": "Service Users"
          },
          "global:Anonymous-Users": {
            "options": {},
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 6fbedb0..2a59d0c 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -60,7 +60,7 @@
 [[details]]
 --
 * `DETAILS`: Includes full name, preferred email, username, display
-name, avatars, status and state for each account.
+name, avatars, status, state and tags for each account.
 --
 
 [[all-emails]]
@@ -1284,6 +1284,7 @@
   )]}'
   {
     "changes_per_page": 25,
+    "theme": "LIGHT",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "diff_view": "SIDE_BY_SIDE",
@@ -1336,6 +1337,7 @@
 
   {
     "changes_per_page": 50,
+    "theme": "DARK",
     "expand_inline_diffs": true,
     "date_format": "STD",
     "time_format": "HHMM_12",
@@ -1383,6 +1385,7 @@
   )]}'
   {
     "changes_per_page": 50,
+    "theme" "DARK",
     "expand_inline_diffs": true,
     "date_format": "STD",
     "time_format": "HHMM_12",
@@ -2299,6 +2302,12 @@
 |`status`          |optional|Status message of the account.
 |`inactive`        |not set if `false`|
 Whether the account is inactive.
+|`tags`      |optional, not set if empty|
+List of additional tags that this account has. The only +
+current tag an account can have is `SERVICE_USER`. +
+Only set if detailed account information is requested. +
+See option link:rest-api-changes.html#detailed-accounts[
+DETAILED_ACCOUNTS]
 |===============================
 
 [[account-input]]
@@ -2759,6 +2768,9 @@
 |`changes_per_page`             ||
 The number of changes to show on each page.
 Allowed values are `10`, `25`, `50`, `100`.
+|`theme`                        ||
+Which theme to use.
+Allowed values are `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (PolyGerrit only).
@@ -2795,9 +2807,11 @@
 |`email_strategy`               ||
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
 |`default_base_for_merges`      ||
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
@@ -2821,6 +2835,9 @@
 |`changes_per_page`             |optional|
 The number of changes to show on each page.
 Allowed values are `10`, `25`, `50`, `100`.
+|`theme`                        |optional|
+Which theme to use.
+Allowed values are `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (PolyGerrit only).
@@ -2855,9 +2872,11 @@
 |`email_strategy`               |optional|
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
 |`default_base_for_merges`      |optional|
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index e9a4caf..32bfc6b 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -74,8 +74,8 @@
 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. 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.
+limit on queries and return all results (does not apply to anonymous requests).
+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
@@ -100,6 +100,15 @@
       "id": "demo~master~Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "project": "demo",
       "branch": "master",
+      "attention_set": [
+        {
+          "account": {
+            "name": "John Doe"
+          },
+         "last_update": "2012-07-17 07:19:27.766000000",
+         "reason": "reviewer or cc replied"
+        }
+      ]
       "change_id": "Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "subject": "One change",
       "status": "NEW",
@@ -213,15 +222,18 @@
 [[labels]]
 --
 * `LABELS`: a summary of each label required for submit, and
-  approvers that have granted (or rejected) with that label.
+  approvers that have granted (or rejected) with that label
+  as well as all reviewers by state, and reviewers that may
+  be removed by the current user.
 --
 
 [[detailed-labels]]
 --
 * `DETAILED_LABELS`: detailed label information, including numeric
   values of all existing approvals, recognized label values, values
-  permitted to be set by the current user, all reviewers by state, and
-  reviewers that may be removed by the current user.
+  permitted to be set by any reviewer and the change owner, all
+  reviewers by state, and reviewers that may be removed by the
+  current user.
 --
 
 [[current-revision]]
@@ -525,6 +537,15 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
+    "attention_set": [
+      {
+        "account": {
+          "name": "John Doe"
+        },
+       "last_update": "2013-02-21 11:16:36.775000000",
+       "reason": "reviewer or cc replied"
+      }
+    ]
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -577,6 +598,18 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
+    "attention_set": [
+      {
+        "account": {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+       "last_update": "2013-02-21 11:16:36.775000000",
+       "reason": "reviewer or cc replied"
+      }
+    ]
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -1132,6 +1165,8 @@
 The request body does not need to include a link:#abandon-input[
 AbandonInput] entity if no review comment is added.
 
+Abandoning a change also removes all users from the link:#attention-set[attention set].
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/abandon HTTP/1.0
@@ -1620,6 +1655,8 @@
 The request body only needs to include a link:#submit-input[
 SubmitInput] entity if submitting on behalf of another user.
 
+Submitting a change also removes all users from the link:#attention-set[attention set].
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/submit HTTP/1.0
@@ -2011,6 +2048,10 @@
 comments for each path are sorted by patch set number. Each comment has
 the `patch_set` and `author` fields set.
 
+If the `enable_context` request parameter is set to true, the comment entries
+will contain a list of link:#context-line[ContextLine] containing the lines of
+the source file where the comment was written.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/comments HTTP/1.0
@@ -2278,6 +2319,9 @@
 is added. Actions that create a new patch set in a WIP change default to
 notifying *OWNER* instead of *ALL*.
 
+Marking a change work in progress also removes all users from the
+link:#attention-set[attention set].
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/wip HTTP/1.0
@@ -2306,6 +2350,9 @@
 to include a link:#work-in-progress-input[WorkInProgressInput] entity
 if no review comment is added.
 
+Marking a change ready for review also adds all of the reviewers of the change
+to the link:#attention-set[attention set].
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ready HTTP/1.0
@@ -3220,6 +3267,10 @@
 a CC on the change is added as reviewer, the reviewer state of that
 user is updated to reviewer.
 
+Adding a new reviewer also adds that reviewer to the attention set, unless
+the change is work in progress.
+Also, moving a reviewer to CC removes that user from the attention set.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers HTTP/1.0
@@ -3361,6 +3412,7 @@
 --
 
 Deletes a reviewer from a change.
+Deleting a reviewer also removes that user from the attention set.
 
 .Request
 ----
@@ -3919,6 +3971,33 @@
 added as a reviewer, otherwise (if they only commented) they are added to
 the CC list.
 
+Some updates to the attention set occur here. If more than one update should
+occur, only the first update in the order of the below documentation occurs:
+
+If a user is part of remove_from_attention_set, the user will be explicitly
+removed from the attention set.
+
+If a user is part of add_to_attention_set, the user will be explicitly
+added to the attention set.
+
+If the boolean ignore_default_attention_set_rules is set to true, all
+other rules below will be ignored:
+
+The user who created the review is removed from the attention set.
+
+If the change is ready for review, the following also apply:
+
+When the uploader replies, the owner is added to the attention set.
+
+When the owner or uploader replies, all the reviewers are added to
+the attention set.
+
+When neither the owner nor the uploader replies, add the owner and the
+uploader to the attention set.
+
+Then, new reviewers are added to the attention set, and removed reviewers
+(by becoming CC) are removed from the attention set.
+
 A review cannot be set on a change edit. Trying to post a review for a
 change edit fails with `409 Conflict`.
 
@@ -4960,6 +5039,137 @@
   }
 ----
 
+[[get-ported-comments]]
+=== Get Ported Comments
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_comments'
+--
+
+Ports comments of other revisions to the requested revision.
+
+Only comments added on earlier patchsets are ported. That set of comments is filtered even further
+due to some additional rules. Callers of this endpoint shouldn't rely on the exact logic of which
+comments are ported as that logic might change in the future. Instead, callers must be able to
+handle any smaller/larger set of comments returned by this endpoint.
+
+Typically, a comment thread is returned fully or excluded fully. However, draft comments and
+robot comments are ignored and not returned via this endpoint. Hence, it's possible to get ported
+comments from this endpoint which are a reply to a non-ported robot comment. Callers must be
+able to deal with this situation.
+
+The returned comments are organized in a map of file path to link:#comment-info[CommentInfo] entries
+in the same fashion as for the link:#list-comments[List Revision Comments] endpoint.
+The map is filled with the original comment attributes except for these attributes: `path`, `line`,
+and `range` point to the computed position in the target revision. If the exactly correct position
+can't be determined, those fields will be filled with the next best position. That can also mean
+not filling the `line` or `range` attribute anymore and thus converting the comment to a file
+comment (or even moving the comment to a different file or the patchset level). Callers of this
+endpoint must be able to deal with this and not rely on the original comment position.
+
+It's possible that this endpoint returns different link:#comment-info[CommentInfo] entries with
+the same comment UUID. This is not a bug but a feature. If a comment appears on a file which Gerrit
+recognizes as copied between patchsets, the ported version of this comment consists of two ported
+instances having the same UUID but different `file`/`line`/`range` positions. Callers must be able
+to handle this situation.
+
+Repeated calls of this endpoint might produce different results. Internal errors during the
+position computation are mapped to fallback locations for affected comments. Those errors might
+have vanished on later calls, upon which this endpoint returns the actually mapped position. In
+addition, comments can be deleted and draft comments can be published, upon which the set of ported
+comments may change.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/4/ported_comments/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": [
+      {
+        "id": "TvcXrmjM",
+        "patch_set": 2,
+        "line": 23,
+        "message": "[nit] trailing whitespace",
+        "updated": "2013-02-26 15:40:43.986000000",
+        "author": {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com"
+        },
+        "unresolved": true
+      },
+      {
+        "id": "TveXwFiA",
+        "patch_set": 2,
+        "line": 23,
+        "in_reply_to": "TvcXrmjM",
+        "message": "Done",
+        "updated": "2013-02-26 15:40:45.328000000",
+        "author": {
+          "_account_id": 1000097,
+          "name": "Jane Roe",
+          "email": "jane.roe@example.com"
+        },
+        "unresolved": true
+      }
+    ]
+  }
+----
+
+[[get-ported-drafts]]
+=== Get Ported Drafts
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_drafts'
+--
+
+Ports draft comments of other revisions to the requested revision.
+
+This endpoint behaves similarly to the link:#get-ported-comments[Get Ported Comments] endpoint.
+With this endpoint, only draft comments of the calling user are ported, though. If a draft comment
+is a reply to a published comment, only the ported draft comment is returned.
+
+Depending on the filtering rules, it's possible that this endpoint returns a draft comment which is
+a reply to a comment thread which is not returned by the
+link:#get-ported-comments[Get Ported Comments] endpoint. That's intended behavior. Callers must be
+able to handle this situation. The same holds for drafts which are a reply to a robot comment.
+
+Different than the link:#get-ported-comments[Get Ported Comments] endpoint, the `author` of the
+returned comments is not filled for this endpoint as only comments of the calling user are returned.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/ported_drafts/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": [
+      {
+        "id": "TveXwFiA",
+        "patch_set": 2,
+        "line": 23,
+        "in_reply_to": "TvcXrmjM",
+        "message": "Done",
+        "updated": "2013-02-26 15:40:45.328000000",
+        "unresolved": true
+      }
+    ]
+  }
+----
+
 [[apply-fix]]
 === Apply Fix
 --
@@ -5384,9 +5594,6 @@
 differences are reported in the result.  Valid values are `IGNORE_NONE`,
 `IGNORE_TRAILING`, `IGNORE_LEADING_AND_TRAILING` or `IGNORE_ALL`.
 
-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
 --
@@ -5676,6 +5883,172 @@
   HTTP/1.1 204 No Content
 ----
 
+[[attention-set-endpoints]]
+== Attention Set Endpoints
+
+[[get-attention-set]]
+=== Get Attention Set
+--
+'GET /changes/link:#change-id[\{change-id\}]/attention'
+--
+
+Returns all users that are currently in the attention set.
+As response a list of link:#attention-set-info[AttentionSetInfo]
+entity is returned.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "account": {
+        "_account_id": 1000096,
+        "name": "John Doe",
+        "email": "john.doe@example.com",
+        "username": "jdoe"
+      },
+      "last_update": "2013-02-01 09:59:32.126000000",
+      "reason": "reviewer or cc replied"
+    },
+    {
+      "account": {
+        "_account_id": 1000097,
+        "name": "Jane Doe",
+        "email": "jane.doe@example.com",
+        "username": "janedoe"
+      },
+      "last_update": "2013-02-01 09:59:32.126000000",
+      "reason": "Reviewer was added"
+    }
+  ]
+----
+
+[[add-to-attention-set]]
+=== Add To Attention Set
+--
+'POST /changes/link:#change-id[\{change-id\}]/attention'
+--
+
+Adds a single user to the attention set of a change.
+
+A user can only be added if they are not in the attention set.
+If a user is added while already in the attention set, the
+request is silently ignored.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
+----
+
+Details should be provided in the request body as an
+link:#attention-set-input[AttentionSetInput] entity.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "user": "John Doe",
+    "reason": "reason"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "_account_id": 1000096,
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "username": "jdoe"
+  }
+----
+
+[[remove-from-attention-set]]
+=== Remove from Attention Set
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/attention/link:rest-api-accounts.html#account-id[\{account-id\}]' +
+'POST /changes/link:#change-id[\{change-id\}]/attention/link:rest-api-accounts.html#account-id[\{account-id\}]/delete'
+--
+
+Deletes a single user from the attention set of a change.
+
+A user can only be removed from the attention set if they
+are currently in the attention set. Otherwise, the request
+is silently ignored.
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention/John%20Doe HTTP/1.0
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention/John%20Doe/delete HTTP/1.0
+----
+
+Reason can be provided in the request body as an
+link:#attention-set-input[AttentionSetInput] entity.
+
+User must be left empty, or the user must be exactly
+the same user as in the request header.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention/John%20Doe/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reason": "reason"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+[[attention-set]]
+== Attention Set
+Attention Set is the set of users that should perform some action on the
+change. E.g, reviewers should review the change, owner/uploader should
+add a new patchset or respond to comments.
+
+Users are added to the attention set if one the following apply:
+
+* They are manually added in link:#review-input[ReviewInput] in
+ add_to_attention_set.
+* They are added as reviewers.
+* The change is marked ready for review.
+* As an owner/uploader, when someone replies on your change.
+* As a reviewer, when the owner/uploader replies.
+
+Users are removed from the attention set if one the following apply:
+
+* They are manually removed in link:#review-input[ReviewInput] in
+ remove_from_attention_set.
+* They are removed from reviewers.
+* The change is marked work in progress, abandoned, or submitted.
+* When the user replies on a change.
+
+If the ignore_default_attention_set_rules in link:#review-input[ReviewInput]
+is set to true, no other changes to the attention set will occur during the
+link:#set-review[set-review].
+Also, users specified in the list will occur instead of any of the implicit
+changes to the attention set. E.g, if a user is added by add_to_attention_set
+in link:#review-input[ReviewInput], but also the change is marked work in
+progress, the user will still be added.
+
 [[ids]]
 == IDs
 
@@ -5730,6 +6103,11 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
+* `/PATCHSET_LEVEL`:
++
+This file path is used exclusively for posting and indicating
+patchset-level comments, thus not relevant for other use-cases.
+
 [[fix-id]]
 === \{fix-id\}
 UUID of a suggested fix.
@@ -5767,7 +6145,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[action-info]]
@@ -5868,6 +6247,42 @@
 should be added as assignee.
 |===========================
 
+[[attention-set-info]]
+=== AttentionSetInfo
+The `AttentionSetInfo` entity contains details of users that are in
+the link:#attention-set[attention set].
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name    ||Description
+|`account`     || link:rest-api-accounts.html#account-info[AccountInfo] entity.
+|`last_update` || The link:rest-api.html#timestamp[timestamp] of the last update.
+|`reason`      || The reason of for adding or removing the user.
+
+|===========================
+[[attention-set-input]]
+=== AttentionSetInput
+The `AttentionSetInput` entity contains details for adding users to the
+link:#attention-set[attention set] and removing them from it.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name        ||Description
+|`user`            |optional| link:rest-api-accounts.html#account-id[ID]
+of the account that should be added to the attention set. For removals,
+this field should be empty or the same as the field in the request header.
+|`reason`          || The reason of for adding or removing the user.
+|`notify`          |optional|
+Notify handling that defines to whom email notifications should be sent
+after the change is created. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `OWNER`.
+|`notify_details`  |optional|
+Additional information about whom to notify about the change creation
+as a map of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
+|===========================
+
 [[blame-info]]
 === BlameInfo
 The `BlameInfo` entity stores the commit metadata with the row coordinates where
@@ -5924,6 +6339,9 @@
 The name of the target branch. +
 The `refs/heads/` prefix is omitted.
 |`topic`              |optional|The topic to which this change belongs.
+|`attention_set`      |optional|
+The map that maps link:rest-api-accounts.html#account-id[account IDs]
+to link:#attention-set-info[AttentionSetInfo] of that account.
 |`assignee`           |optional|
 The assignee of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
@@ -5999,7 +6417,8 @@
 |`removable_reviewers`|optional|
 The reviewers that can be removed by the calling user as a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities. +
-Only set if link:#detailed-labels[detailed labels] are requested.
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`reviewers`          |optional|
 The reviewers as a map that maps a reviewer state to a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities.
@@ -6008,13 +6427,15 @@
 `CC`: Users that were added to the change, but have not voted. +
 `REMOVED`: Users that were previously reviewers on the change, but have
 been removed. +
-Only set if link:#detailed-labels[detailed labels] are requested.
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`pending_reviewers`  |optional|
 Updates to `reviewers` that have been made while the change was in the
 WIP state. Only present on WIP changes and only if there are pending
 reviewer updates to report. These are reviewers who have not yet been
 notified about being added to or removed from the change. +
-Only set if link:#detailed-labels[detailed labels] are requested.
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`reviewer_updates`|optional|
 Updates to reviewers set for the change as
 link:#review-update-info[ReviewerUpdateInfo] entities.
@@ -6131,7 +6552,8 @@
 If not set, the default is `ALL`.
 |`notify_details`     |optional|
 Additional information about whom to notify about the change creation
-as a map of recipient type to link:#notify-info[NotifyInfo] entity.
+as a map of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |==================================
 
 [[change-message-info]]
@@ -6187,7 +6609,8 @@
 If not set, the default is `ALL`.
 |`notify_details`   |optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |`keep_reviewers`   |optional, defaults to false|
 If `true`, carries reviewers and ccs over from original change to newly created one.
 |`allow_conflicts`  |optional, defaults to false|
@@ -6224,7 +6647,7 @@
 comments may be returned for multiple patch sets.
 |`id`          ||The URL encoded UUID of the comment.
 |`path`        |optional|
-The path of the file for which the inline comment was done. +
+link:#file-id[The file path] for which the inline comment was done. +
 Not set if returned in a map where the key is the file path.
 |`side`        |optional|
 The side on which the comment was added. +
@@ -6260,9 +6683,17 @@
 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.
+Available with the link:#list-change-comments[list change comments] endpoint.
+Contains the link:rest-api-changes.html#change-message-info[id] of the change
+message that this comment is linked to.
+|`commit_id` |optional|
+Hex commit SHA1 (40 characters string) of the commit of the patchset to which
+this comment applies.
+|`context_lines` |optional|
+A list of link:#context-line[ContextLine] containing the lines of the source
+file where the comment was written. Available only if the "enable_context"
+parameter (see link:#list-change-comments[List Change Comments]) is set.
+
 |===========================
 
 [[comment-input]]
@@ -6277,7 +6708,7 @@
 The URL encoded UUID of the comment if an existing draft comment should
 be updated.
 |`path`        |optional|
-The path of the file for which the inline comment should be added. +
+link:#file-id[The file path] for which the inline comment should be added. +
 Doesn't need to be set if contained in a map where the key is the file
 path.
 |`side`        |optional|
@@ -6334,6 +6765,18 @@
 |`end_character`     ||The character position in the end line. (0-based)
 |===========================
 
+[[context-line]]
+=== ContextLine
+The `ContextLine` entity contains the line number and line text of a single
+line of the source file content.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name          |Description
+|`line_number`       |The line number of the source line.
+|`context_line`      |String containing the line text.
+|===========================
+
 [[commit-info]]
 === CommitInfo
 The `CommitInfo` entity contains information about a commit.
@@ -6377,7 +6820,8 @@
 If not set, the default is `OWNER` for WIP changes and `ALL` otherwise.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[delete-change-message-input]]
@@ -6423,7 +6867,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[delete-vote-input]]
@@ -6444,7 +6889,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[description-input]]
@@ -6478,6 +6924,8 @@
 link:#diff-intraline-info[DiffIntralineInfo] entity.
 |`due_to_rebase`|not set if `false`|Indicates whether this entry was introduced by a
 rebase.
+|`due_to_move`|not set if `false`|Indicates whether this entry was introduced by a
+move operation.
 |`skip`         |optional|count of lines skipped on both sides when the file is
 too large to include all common lines.
 |`common`       |optional|Set to `true` if the region is common according
@@ -6684,12 +7132,13 @@
 |==========================
 |Field Name      |Description
 |`path`          |The path of the file which should be modified. Any file in
-the repository may be modified.
+the repository may be modified. The commit message can be modified via the
+magic file `/COMMIT_MSG` though only the part below the generated header of
+that magic file can be modified. References to the header lines will result in
+errors when the fix is applied.
 |`range`         |A <<comment-range,CommentRange>> indicating which content
 of the file should be replaced. Lines in the file are assumed to be separated
-by the line feed character, the carriage return character, the carriage return
-followed by the line feed character, or one of the other Unicode linebreak
-sequences supported by Java.
+by the line feed character.
 |`replacement`   |The content which should be used instead of the current one.
 |==========================
 
@@ -6888,6 +7337,13 @@
 |`merge`              ||
 The detail of the source commit for merge as a link:#merge-input[MergeInput]
 entity.
+|`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.
 |==================================
 
 [[move-input]]
@@ -6908,8 +7364,8 @@
 be notified about an update. These notifications are sent out even if a
 `notify` option in the request input disables normal notifications.
 `NotifyInfo` entities are normally contained in a `notify_details` map
-in the request input where the key is the recipient type. The recipient
-type can be `TO`, `CC` and `BCC`.
+in the request input where the key is the
+link:user-notify.html#recipient-types[recipient type].
 
 [options="header",cols="1,^1,5"]
 |=======================
@@ -6964,7 +7420,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[pure-revert-info]]
@@ -7100,12 +7557,17 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the revert as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |`topic`         |optional|
 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.
+|`work_in_progress` |optional|
+When present, change is marked as Work In Progress. This will also override
+the notify value to `OWNER`. +
+If not set, the default is false.
 |=============================
 
 [[revert-submission-info]]
@@ -7160,24 +7622,24 @@
 
 [options="header",cols="1,^1,5"]
 |============================
-|Field Name               ||Description
-|`message`                |optional|
+|Field Name                             ||Description
+|`message`                              |optional|
 The message to be added as review comment.
-|`tag`                    |optional|
+|`tag`                                  |optional|
 Apply this tag to the review comment message, votes, and inline
 comments. Tags may be used by CI or other automated systems to
 distinguish them from human reviews. Votes/comments that contain `tag` with
 'autogenerated:' prefix can be filtered out in the web UI.
-|`labels`                 |optional|
+|`labels`                               |optional|
 The votes that should be added to the revision as a map that maps the
 label names to the voting values.
-|`comments`               |optional|
+|`comments`                             |optional|
 The comments that should be added as a map that maps a file path to a
 list of link:#comment-input[CommentInput] entities.
-|`robot_comments`         |optional|
+|`robot_comments`                       |optional|
 The robot comments that should be added as a map that maps a file path
 to a list of link:#robot-comment-input[RobotCommentInput] entities.
-|`drafts`                 |optional|
+|`drafts`                               |optional|
 Draft handling that defines how draft comments are handled that are
 already in the database but that were not also described in this
 input. +
@@ -7186,29 +7648,40 @@
 Only `KEEP` is allowed when used in conjunction with `on_behalf_of`. +
 If not set, the default is `KEEP`. If `on_behalf_of` is set, then no other value
 besides `KEEP` is allowed.
-|`notify`                 |optional|
+|`notify`                              |optional|
 Notify handling that defines to whom email notifications should be sent
 after the review is stored. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`         |optional|
+|`notify_details`                      |optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
-|`omit_duplicate_comments`|optional|
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
+|`omit_duplicate_comments`             |optional|
 If `true`, comments with the same content at the same place will be omitted.
-|`on_behalf_of`           |optional|
+|`on_behalf_of`                        |optional|
 link:rest-api-accounts.html#account-id[\{account-id\}] the review
 should be posted on behalf of. To use this option the caller must
 have been granted `labelAs-NAME` permission for all keys of labels.
-|`reviewers`              |optional|
+|`reviewers`                           |optional|
 A list of link:rest-api-changes.html#reviewer-input[ReviewerInput]
 representing reviewers that should be added to the change.
-|`ready`                  |optional|
+|`ready`                               |optional|
 If true, and if the change is work in progress, then start review.
 It is an error for both `ready` and `work_in_progress` to be true.
-|`work_in_progress`         |optional|
+|`work_in_progress`                    |optional|
 If true, mark the change as work in progress. It is an error for both
 `ready` and `work_in_progress` to be true.
+|`add_to_attention_set`                |optional|
+list of link:#attention-set-input[AttentionSetInput] entities to add
+to the link:#attention-set[attention set].
+|`remove_from_attention_set`           |optional|
+list of link:#attention-set-input[AttentionSetInput] entities to remove
+from the link:#attention-set[attention set].
+|`ignore_automatic_attention_set_rules`|optional|
+If set to true, ignore all automatic attention set rules described in the
+link:#attention-set[attention set]. Updates in add_to_attention_set
+and remove_from_attention_set are not ignored.
 |============================
 
 [[review-result]]
@@ -7286,7 +7759,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[revision-info]]
@@ -7352,7 +7826,8 @@
 The `RobotCommentInfo` entity contains information about a robot inline
 comment.
 
-`RobotCommentInfo` has the same fields as <<comment-info,CommentInfo>>.
+`RobotCommentInfo` has the same fields as <<comment-info,CommentInfo>>
+except for the `unresolved` field which doesn't exist for robot comments.
 In addition `RobotCommentInfo` has the following fields:
 
 [options="header",cols="1,^1,5"]
@@ -7372,8 +7847,35 @@
 The `RobotCommentInput` entity contains information for creating an inline
 robot comment.
 
-`RobotCommentInput` has the same fields as
-<<robot-comment-info,RobotCommentInfo>>.
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name    ||Description
+|`path`        ||
+link:#file-id[The file path] for which the inline comment should be added.
+|`side`        |optional|
+The side on which the comment should be added. +
+Allowed values are `REVISION` and `PARENT`. +
+If not set, the default is `REVISION`.
+|`line`        |optional|
+The number of the line for which the comment should be added. +
+`0` if it is a file comment. +
+If neither line nor range is set, a file comment is added. +
+If range is set, this value is ignored in favor of the `end_line` of the range.
+|`range`       |optional|
+The range of the comment as a link:#comment-range[CommentRange]
+entity.
+|`in_reply_to` |optional|
+The URL encoded UUID of the comment to which this comment is a reply.
+|`message`     |optional|
+The comment message.
+|`robot_id`       ||The ID of the robot that generated this comment.
+|`robot_run_id`   ||An ID of the run of the robot.
+|`url`            |optional|URL to more information.
+|`properties`     |optional|Robot specific properties as map that maps arbitrary
+keys to values.
+|`fix_suggestions`|optional|Suggested fixes for this robot comment as a list of
+<<fix-suggestion-info,FixSuggestionInfo>> entities.
+|===========================
 
 [[rule-input]]
 === RuleInput
@@ -7433,7 +7935,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[submit-record]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index f76e0b8..a62ed47 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1701,7 +1701,7 @@
 |======================
 |Field Name|Description
 |`status`  |The status of the consistency problem. +
-Possible values are `ERROR` and `WARNING`.
+Possible values are `FATAL`, `ERROR` and `WARNING`.
 |`message` |Message describing the consistency problem.
 |======================
 
@@ -2011,7 +2011,7 @@
 UserConfigInfo] entity.
 |`default_theme`           |optional|
 URL to a default PolyGerrit UI theme plugin, if available.
-Located in `/static/gerrit-theme.html` by default.
+Located in `/static/gerrit-theme.js` by default.
 |=======================================
 
 [[sshd-info]]
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index 72974e2..52c505a 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -67,12 +67,12 @@
       "owner_id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7",
       "created_on": "2013-02-01 09:59:32.126000000"
     },
-    "Non-Interactive Users": {
+    "Service Users": {
       "id": "5057f3cbd3519d6ab69364429a89ffdffba50f73",
       "url": "#/admin/groups/uuid-5057f3cbd3519d6ab69364429a89ffdffba50f73",
       "options": {
       },
-      "description": "Users who perform batch actions on Gerrit",
+      "description": "Service accounts that interact with Gerrit",
       "group_id": 4,
       "owner": "Administrators",
       "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 7bb8084..84da169 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1153,7 +1153,7 @@
         "owner": "Administrators",
         "owner_id": "d5b7124af4de52924ed397913e2c3b37bf186948",
         "created_on": "2009-06-08 23:31:00.000000000",
-        "name": "Non-Interactive Users"
+        "name": "Service Users"
       },
       "global:Anonymous-Users": {
         "options": {},
@@ -1253,6 +1253,57 @@
   }
 ----
 
+[[create-change]]
+=== Create Change for review.
+
+This endpoint is functionally equivalent to
+link:rest-api-changes.html#create-change[create change in the change
+API], but it has the project name in the URL, which is easier to route
+in sharded deployments.
+
+.Request
+----
+  POST /projects/myProject/create.change HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "subject" : "Let's support 100% Gerrit workflow direct in browser",
+    "branch" : "master",
+    "topic" : "create-change-in-browser",
+    "status" : "NEW"
+  }
+----
+
+As response a link:#change-info[ChangeInfo] entity is returned that describes
+the resulting change.
+
+.Response
+----
+  HTTP/1.1 201 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "project": "myProject",
+    "branch": "master",
+    "topic": "create-change-in-browser",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "subject": "Let's support 100% Gerrit workflow direct in browser",
+    "status": "NEW",
+    "created": "2014-05-05 07:15:44.639000000",
+    "updated": "2014-05-05 07:15:44.639000000",
+    "mergeable": true,
+    "insertions": 0,
+    "deletions": 0,
+    "_number": 4711,
+    "owner": {
+      "name": "John Doe"
+    }
+  }
+----
+
 [[create-access-change]]
 === Create Access Rights Change for review.
 --
@@ -3589,7 +3640,7 @@
 |`plugin_config`                           |optional|
 Plugin configuration as map which maps the plugin name to a map of
 parameter names to link:#config-parameter-info[ConfigParameterInfo]
-entities.
+entities. Only filled for users who have read access to `refs/meta/config`.
 |`actions`                                 |optional|
 Actions the caller might be able to perform on this project. The
 information is a map of view names to
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
new file mode 100644
index 0000000..5e2906f
--- /dev/null
+++ b/Documentation/user-attention-set.txt
@@ -0,0 +1,191 @@
+= Gerrit Code Review - Attention Set
+
+Report a bug or send feedback using
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set[this Monorail template].
+You can also report a bug through the bug icon in the user hovercard and in the
+reply dialog.
+
+[[whose-turn]]
+== Whose turn is it?
+
+Code Review is a turn-based workflow going back and forth between the change
+owner and reviewers. For every change Gerrit maintains an "Attention Set" with
+users that are currently expected to act on the change. Both on the dashboard
+and on the change page, this is expressed by an arrow icon before a (bolded)
+user name:
+
+image::images/user-attention-set-icon.png["account chip with attention icon", align="center"]
+
+While the attention set brings clarity to the process it also comes with
+responsibilities and expectations. To provide the best outcome for all users, we
+suggest following these principles:
+
+* Reviewers are expected to respond in a timely manner when it is their turn. If
+  you don't plan to respond within ~24h, then you should either remove yourself
+  from the attention set or you should at least send a clarification message to
+  the change owner.
+* Change owners are expected to manage the attention set of their changes
+  carefully. They should make sure that reviewers are only in the attention set
+  when the owner waits for a response from them.
+
+On the plus side you can strictly ignore everyone else's changes, if you are not
+in the attention set. :-)
+
+=== Rules
+
+To help with the back and forth, Gerrit applies some basic automated rules for
+changing the attention set:
+
+* If reviewers are added to a change, then they are added to the attention set.
+  * Exception: A reviewer adding themselves along with a comment or vote.
+* If an active change is submitted, abandoned or reset to "work in progress",
+  then all users are removed from the attention set.
+* Replying (commenting, voting or just writing a change message) removes the
+  replying user from the attention set. And it adds all participants of comment
+  conversations that the user is replying to.
+* If a *reviewer* replies, then the change owner (and uploader) are added to the
+  attention set.
+* For merged and abandoned changes the owner is added only when a human creates
+  an unresolved comment.
+* Only owner, uploader, reviewers and ccs can be in the attention set.
+
+*!IMPORTANT!* These rules are not meant to be super smart and to always do the
+right thing, e.g. if the change owner sends a reply, then they are often
+expected to individually select whose turn it is.
+
+Note that just uploading a new patchset is not a relevant event for the
+attention set to change.
+
+=== Interaction
+
+There are three ways to interact with the attention set: The attention icon,
+the hovercard of owner and reviewer chips and the "Reply" dialog.
+
+*The attention icon* can be used to quickly remove yourself (or someone else)
+from the attention set. Just click the icon, and it will disappear:
+
+image::images/user-attention-set-icon-click.png["attention set icon with tooltip", align="center"]
+
+*The hovercard* (on both the Dashboard and Change page) contains information
+about whether, why and when a user was added to the attention set. It also
+contains an action for adding/removing the user to/from the attention set.
+
+image::images/user-attention-set-hovercard.png["user hovercard with info and action", align="center"]
+
+*The reply dialog* contains a section for controlling to whom the turn should be
+passed.
+
+image::images/user-attention-set-reply-modify.png["reply dialog section for modifying", align="center"]
+
+If you click "MODIFY", then the section will
+expand and you can select and de-select users by clicking on their chips.
+Whatever you select here will be the new state of the attention set for this
+change. As a change owner make sure to remove reviewers that you don't expect to
+take action.
+
+image::images/user-attention-set-reply-select.png["reply dialog section for selecting users", align="center"]
+
+=== Bots
+
+The attention set is meant for human reviews only. Triggering bots and reacting
+to their results is a different workflow and not in scope of the attenion set.
+Thus members of the "Service Users" group will never be added to the
+attention set. And replies by such users will only add the change owner (and
+uploader) to the attention set, if it comes along with a negative vote.
+
+=== Dashboard
+
+The default *dashboard* contains a new section at the top called "Your Turn". It
+lists all changes where the logged-in user is in the attention set. When you are
+a reviewer, the change is highlighted and is shown at the top of the section.
+The "Waiting" column indicates how long the owner has already been waiting for
+you to act.
+
+image::images/user-attention-set-dashboard.png["dashboard with Your Turn section", align="center"]
+
+As an active developer, one of your daily goals will be to iterate over this
+list and clear it.
+
+image::images/user-attention-set-dashboard-empty.png["dashboard with empty Your Turn section", align="center"]
+
+Note that you can also navigate to other users' dashboards to check their
+"Your Turn" section.
+
+=== Emails
+
+Every email begins with `Attention is currently required from: ...`, so you can
+identify at a glance whether you are expected to act.
+
+You can even change your email notification preferences in the user settings to
+only receive emails when you are in the attention set of a change:
+
+image::images/user-attention-set-user-prefs.png["user preference for email notifications", align="center"]
+
+If you prefer setting up customized filters in your mail client, then you can
+make use of the `Gerrit-Attention:` footer lines that are added for every user
+in the attention set, e.g.
+
+----
+Gerrit-Attention: Marian Harbach <mharbach@google.com>
+----
+
+=== Assignee
+
+While the "Assignee" feature can still be used together with the attention set,
+we do not recommend doing so. Using both features is likely confusing. The
+distinct feature of the "Assignee" compared to the attention set is that only
+one user can be the assignee at the same time. So the assignee can be used to
+single out one person or escalate, if there are multiple reviewers. Since
+*every* reviewer in the attention set is expected to take action, singling out
+is not likely to be important and also still achievable with the attention set.
+Otherwise "Assignee" and "Attention Set" are very much overlapping, so we
+recommend to only use one of them.
+
+If you don't expect action from reviewers, then consider adding them to CC
+instead.
+
+The "Assignee" feature can be turned on/off with the
+link:config-gerrit.html#change.enableAttentionSet[enableAssignee] config option.
+
+=== Bold Changes / Mark Reviewed
+
+Before the attention set feature, changes were bolded in the dashboard when
+*something* happened and you could explicitly "mark a change reviewed" on the
+change page. This former way of keeping track of what you should look at has
+been replaced by the attention set.
+
+=== For Gerrit Admins
+
+The Attention Set has been available since the 3.3 release (late 2020). It
+is enabled by default, but you can disable it by setting
+link:config-gerrit.html#change.enableAttentionSet[enableAttentionSet] to false.
+
+As part of Gerrit 3.3 upgrade, the user group "Non-Interactive Users" is
+renamed "Service Users". For a new installation, the group is automatically
+created upon initialization.
+
+=== Important note for all host owners, project owners, and bot owners
+
+If you are a host/project owner, please make sure all bots that run against your
+host/project are part of the link:access-control.html#service_users[Service Users] group.
+
+If you are a bot owner, please make sure your bot is part of the "Service Users"
+group on all hosts it runs on.
+
+To add users to the "Service Users" group, first ensure that the group exists on
+your host. If it doesn't, create it. The name must exactly be "Service Users".
+
+To create a group, use the Gerrit UI; BROWSE -> Groups -> CREATE NEW.
+
+Then, add the bots as members in this group. Alternatively, add an existing
+group that has multiple bots as a subgroup of "Service Users".
+
+To add members or subgroups, use the Gerrit UI; BROWSE -> Groups ->
+search for "Service Users" -> Members.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-dashboards.txt b/Documentation/user-dashboards.txt
index e64d625..364b0d9 100644
--- a/Documentation/user-dashboards.txt
+++ b/Documentation/user-dashboards.txt
@@ -12,7 +12,7 @@
 
 Dashboards are available via URLs like:
 ----
-  /#/dashboard/?title=Custom+View&To+Review=reviewer:john.doe@example.com&Pending+In+myproject=project:myproject+is:open
+  /dashboard/?title=Custom+View&To+Review=reviewer:john.doe@example.com&Pending+In+myproject=project:myproject+is:open
 ----
 This opens a view showing the title "Custom View" with two sections,
 "To Review" and "Pending in myproject":
@@ -51,7 +51,7 @@
 to changes for the current user:
 
 ----
-  /#/dashboard/?title=Mine&foreach=owner:self&My+Pending=is:open&My+Merged=is:merged
+  /dashboard/?title=Mine&foreach=owner:self&My+Pending=is:open&My+Merged=is:merged
 ----
 
 
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 5346b2e..5ee3136 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -6,6 +6,15 @@
 uploaded for review, after comments have been posted on a change,
 or after the change has been submitted to a branch.
 
+[[recipient-types]]
+== Recipient Type
+
+Those are the available recipient types:
++
+* `to`: The standard To field is used; addresses are visible to all.
+* `cc`: The standard CC field is used; addresses are visible to all.
+* `bcc`: SMTP RCPT TO is used to hide the address.
+
 [[user]]
 == User Level Settings
 
@@ -114,10 +123,8 @@
 Email header used to list the destination. If not set BCC is used.
 Only one value may be specified. To use different headers for each
 address list them in different notify blocks.
-+
-* `to`: The standard To field is used; addresses are visible to all.
-* `cc`: The standard CC field is used; addresses are visible to all.
-* `bcc`: SMTP RCPT TO is used to hide the address.
+
+The possible options are the link:#recipient-types[recipient types].
 
 [[notify.name.filter]]notify.<name>.filter::
 +
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 8caf656..b22788a 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -556,14 +556,17 @@
 author:'AUTHOR'::
 +
 Changes where 'AUTHOR' is the author of the current patch set. 'AUTHOR' may be
-the author's exact email address, or part of the name or email address.
+the author's exact email address, or part of the name or email address. The
+special case of `author:self` will find changes authored by the caller.
 
 [[committer]]
 committer:'COMMITTER'::
 +
 Changes where 'COMMITTER' is the committer of the current patch set.
 'COMMITTER' may be the committer's exact email address, or part of the name or
-email address.
+email address. The special case of `committer:self` will find changes committed
+by the caller.
+
 
 [[submittable]]
 submittable:'SUBMIT_STATUS'::
diff --git a/README.md b/README.md
index a76dac6..8a4379b 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,8 @@
 [Gerrit](https://www.gerritcodereview.com) is a code review and project
 management tool for Git based projects.
 
-[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-master/)
+[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/)
+![Maven Central](https://img.shields.io/maven-central/v/com.google.gerrit/gerrit-war)
 
 ## Objective
 
@@ -76,13 +77,13 @@
 
 Docker images of Gerrit are available on [DockerHub](https://hub.docker.com/u/gerritforge/)
 
-To run a CentOS 7 based Gerrit image:
+To run a CentOS 8 based Gerrit image:
 
-        docker run -p 8080:8080 gerritforge/gerrit-centos7[:version]
+        docker run -p 8080:8080 gerritcodereview/gerrit[:version]-centos8
 
-To run a Ubuntu 15.04 based Gerrit image:
+To run a Ubuntu 20.04 based Gerrit image:
 
-        docker run -p 8080:8080 gerritforge/gerrit-ubuntu15.04[:version]
+        docker run -p 8080:8080 gerritcodereview/gerrit[:version]-ubuntu20
 
 _NOTE: release is optional. Last released package of the version is installed if the release
 number is omitted._
diff --git a/WORKSPACE b/WORKSPACE
index 3b60ea9..f909ed9 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -10,6 +10,8 @@
 #    @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.
+# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit plugins.
+#    The packages here are expected to be used in plugins.
 # 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.
@@ -20,6 +22,7 @@
         "@ui_npm": ["polygerrit-ui/app/node_modules"],
         "@ui_dev_npm": ["polygerrit-ui/node_modules"],
         "@tools_npm": ["tools/node_tools/node_modules"],
+        "@plugins_npm": ["plugins/node_modules"],
     },
 )
 
@@ -30,73 +33,80 @@
 load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
 
 http_archive(
-    name = "bazel_toolchains",
-    sha256 = "1adf7a8e9901287c644dcf9ca08dd8d67a69df94bedbd57a841490a84dc1e9ed",
-    strip_prefix = "bazel-toolchains-5.0.0",
+    name = "platforms",
+    sha256 = "379113459b0feaf6bfbb584a91874c065078aa673222846ac765f86661c27407",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/v5.0.0.tar.gz",
-        "https://github.com/bazelbuild/bazel-toolchains/archive/v5.0.0.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
+        "https://github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
     ],
 )
 
-load("@bazel_toolchains//rules:rbe_repo.bzl", "rbe_autoconfig")
-
-# Creates a default toolchain config for RBE.
-# Use this as is if you are using the rbe_ubuntu16_04 container,
-# otherwise refer to RBE docs.
-rbe_autoconfig(name = "rbe_default")
-
-# TODO(davido): Switch to upstream again, when this PR is merged:
-# https://github.com/bazelbuild/rules_closure/pull/478
 http_archive(
-    name = "io_bazel_rules_closure",
-    sha256 = "b9c2bc6ba377aa497eb7c31681d34404febf9d4e3c9c7d98ce0d78238a0af20f",
-    strip_prefix = "rules_closure-0.31",
+    name = "rbe_jdk11",
+    sha256 = "766796de71916118e528b9f4334c29c9c9b4e926227bf3264dee555e6a4306c8",
+    strip_prefix = "rbe_autoconfig-2.0.0",
     urls = [
-        "https://github.com/davido/rules_closure/archive/V0.31.tar.gz",
-        "https://gerrit-ci.gerritforge.com/lib/V0.31.tar.gz",
+        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v2.0.0.tar.gz",
+        "https://github.com/davido/rbe_autoconfig/archive/v2.0.0.tar.gz",
     ],
 )
 
 http_archive(
+    name = "com_google_protobuf",
+    sha256 = "71030a04aedf9f612d2991c1c552317038c3c5a2b578ac4745267a45e7037c29",
+    strip_prefix = "protobuf-3.12.3",
+    urls = [
+        "https://github.com/protocolbuffers/protobuf/archive/v3.12.3.tar.gz",
+    ],
+)
+
+load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
+
+protobuf_deps()
+
+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"],
+    sha256 = "c077680a307eb88f3e62b0b662c2e9c6315319385bc8c637a861ffdbed8ca247",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.1.0/rules_nodejs-5.1.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
-http_file(
-    name = "polymer_closure",
-    downloaded_file_path = "polymer_closure.js",
-    sha256 = "4d63a36dcca040475bd6deb815b9a600bd686e1413ac1ebd4b04516edd675020",
-    urls = ["https://raw.githubusercontent.com/google/closure-compiler/35d2b3340ff23a69441f10fa3bc820691c2942f2/contrib/externs/polymer-1.0.js"],
+load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
+
+build_bazel_rules_nodejs_dependencies()
+
+# This is required just because we have a dependency on @bazel/concatjs.
+# We don't actually use any of this web_testing stuff.
+# TODO: Remove this dependency.
+http_archive(
+    name = "io_bazel_rules_webtesting",
+    sha256 = "e9abb7658b6a129740c0b3ef6f5a2370864e102a5ba5ffca2cea565829ed825a",
+    urls = [
+        "https://github.com/bazelbuild/rules_webtesting/releases/download/0.3.5/rules_webtesting.tar.gz",
+    ],
 )
 
-load("@io_bazel_rules_closure//closure:repositories.bzl", "rules_closure_dependencies", "rules_closure_toolchains")
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+load("@io_bazel_rules_webtesting//web:repositories.bzl", "web_test_repositories")
 
-# Prevent redundant loading of dependencies.
-# TODO(davido): Omit re-fetching ancient args4j version when these PRs are merged:
-# https://github.com/bazelbuild/rules_closure/pull/262
-# https://github.com/google/closure-templates/pull/155
-rules_closure_dependencies(
-    omit_aopalliance = True,
-    omit_javax_inject = True,
-    omit_rules_cc = True,
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+web_test_repositories()
+
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+load("@io_bazel_rules_webtesting//web/versioned:browsers-0.3.3.bzl", "browser_repositories")
+
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+browser_repositories(
+    chromium = True,
+    firefox = True,
 )
 
-rules_closure_toolchains()
-
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "b34cbe1a7514f5f5487c3bfee7340a4496713ddf4f119f7a225583d6cafd793a",
+    sha256 = "a8d6b1b354d371a646d2f7927319974e0f9e52f73a2452d2b3877118169eb6bb",
     urls = [
-        "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",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
     ],
 )
 
@@ -108,8 +118,11 @@
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "3c681998538231a2d24d0c07ed5a7658cb72bfb5fd4bf9911157c0e9ac6a2687",
-    urls = ["https://github.com/bazelbuild/bazel-gazelle/releases/download/0.17.0/bazel-gazelle-0.17.0.tar.gz"],
+    sha256 = "cdb02a887a7187ea4d5a27452311a75ed8637379a1287d8eeb952138ea485f7d",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+    ],
 )
 
 load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies", "go_repository")
@@ -156,36 +169,6 @@
     sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
 )
 
-GUICE_VERS = "4.2.3"
-
-GUICE_LIBRARY_SHA256 = "5168f5e7383f978c1b4154ac777b78edd8ac214bb9f9afdb92921c8d156483d3"
-
-http_file(
-    name = "guice-library-no-aop",
-    canonical_id = "guice-library-no-aop-" + GUICE_VERS + ".jar-" + GUICE_LIBRARY_SHA256,
-    downloaded_file_path = "guice-library-no-aop.jar",
-    sha256 = GUICE_LIBRARY_SHA256,
-    urls = [
-        "https://repo1.maven.org/maven2/com/google/inject/guice/" +
-        GUICE_VERS +
-        "/guice-" +
-        GUICE_VERS +
-        "-no_aop.jar",
-    ],
-)
-
-maven_jar(
-    name = "guice-assistedinject",
-    artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
-    sha1 = "acbfddc556ee9496293ed1df250cc378f331d854",
-)
-
-maven_jar(
-    name = "guice-servlet",
-    artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
-    sha1 = "8d6e7e35eac4fb5e7df19c55b3bc23fa51b10a11",
-)
-
 maven_jar(
     name = "javax_inject",
     artifact = "javax.inject:javax.inject:1",
@@ -194,8 +177,8 @@
 
 maven_jar(
     name = "servlet-api",
-    artifact = "org.apache.tomcat:tomcat-servlet-api:8.5.23",
-    sha1 = "021a212688ec94fe77aff74ab34cc74f6f940e60",
+    artifact = "javax.servlet:javax.servlet-api:3.1.0",
+    sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
 )
 
 # JGit's transitive dependencies
@@ -284,38 +267,6 @@
     sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
 )
 
-SLF4J_VERS = "1.7.26"
-
-maven_jar(
-    name = "log-api",
-    artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
-    sha1 = "77100a62c2e6f04b53977b9f541044d7d722693d",
-)
-
-maven_jar(
-    name = "log-ext",
-    artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
-    sha1 = "31cdf122e000322e9efcb38913e9ab07825b17ef",
-)
-
-maven_jar(
-    name = "impl-log4j",
-    artifact = "org.slf4j:slf4j-log4j12:" + SLF4J_VERS,
-    sha1 = "12f5c685b71c3027fd28bcf90528ec4ec74bf818",
-)
-
-maven_jar(
-    name = "jcl-over-slf4j",
-    artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
-    sha1 = "33fbc2d93de829fa5e263c5ce97f5eab8f57d53e",
-)
-
-maven_jar(
-    name = "log4j",
-    artifact = "log4j:log4j:1.2.17",
-    sha1 = "5af35056b4d257e4b64b9e8069c0746e8b08629f",
-)
-
 maven_jar(
     name = "json-smart",
     artifact = "net.minidev:json-smart:1.1.1",
@@ -323,7 +274,7 @@
 )
 
 maven_jar(
-    name = "args4j-intern",
+    name = "args4j",
     artifact = "args4j:args4j:2.33",
     sha1 = "bd87a75374a6d6523de82fef51fc3cfe9baf9fc9",
 )
@@ -950,12 +901,6 @@
 )
 
 maven_jar(
-    name = "commons-io",
-    artifact = "commons-io:commons-io:2.2",
-    sha1 = "83b5b8a7ba1c08f9e8c8ff2373724e33d3c1e22a",
-)
-
-maven_jar(
     name = "asciidoctor",
     artifact = "org.asciidoctor:asciidoctorj:1.5.7",
     sha1 = "8e8c1d8fc6144405700dd8df3b177f2801ac5987",
@@ -999,6 +944,60 @@
     sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
 )
 
+load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
+
+node_repositories(
+    node_version = "16.13.2",
+    yarn_version = "1.22.17",
+)
+
+yarn_install(
+    name = "npm",
+    exports_directories_only = False,
+    frozen_lockfile = False,
+    package_json = "//:package.json",
+    symlink_node_modules = True,
+    yarn_lock = "//:yarn.lock",
+)
+
+yarn_install(
+    name = "ui_npm",
+    args = ["--prod"],
+    exports_directories_only = False,
+    frozen_lockfile = False,
+    package_json = "//:polygerrit-ui/app/package.json",
+    symlink_node_modules = True,
+    yarn_lock = "//:polygerrit-ui/app/yarn.lock",
+)
+
+yarn_install(
+    name = "ui_dev_npm",
+    exports_directories_only = False,
+    frozen_lockfile = False,
+    package_json = "//:polygerrit-ui/package.json",
+    symlink_node_modules = True,
+    yarn_lock = "//:polygerrit-ui/yarn.lock",
+)
+
+yarn_install(
+    name = "tools_npm",
+    exports_directories_only = False,
+    frozen_lockfile = False,
+    package_json = "//:tools/node_tools/package.json",
+    symlink_node_modules = True,
+    yarn_lock = "//:tools/node_tools/yarn.lock",
+)
+
+yarn_install(
+    name = "plugins_npm",
+    args = ["--prod"],
+    exports_directories_only = False,
+    frozen_lockfile = False,
+    package_json = "//:plugins/package.json",
+    symlink_node_modules = True,
+    yarn_lock = "//:plugins/yarn.lock",
+)
+
 load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
 
 # NPM binaries bundled along with their dependencies.
@@ -1163,8 +1162,8 @@
 bower_archive(
     name = "codemirror-minified",
     package = "Dominator008/codemirror-minified",
-    sha1 = "d00f3b97345772d5a7790f206cb1e3c22e96caf6",
-    version = "5.50.2",
+    sha1 = "904bae2a8716087fd21e92324e8a136a0c4a99b7",
+    version = "5.62.2",
 )
 
 # bower test stuff
@@ -1190,40 +1189,4 @@
     version = "6.5.1",
 )
 
-load("@build_bazel_rules_nodejs//:index.bzl", "yarn_install")
-
-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/antlr3/BUILD b/antlr3/BUILD
index 549946a..23641e3 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -22,6 +22,7 @@
     srcs = [":query"],
     visibility = [
         "//java/com/google/gerrit/index:__subpackages__",
+        "//java/com/google/gerrit/server:__subpackages__",
         "//javatests/com/google/gerrit:__subpackages__",
         "//javatests/com/google/gerrit/index:__pkg__",
         "//plugins:__pkg__",
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index bc6a71c..482c804 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -49,21 +49,23 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-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.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -89,8 +91,6 @@
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -436,18 +436,20 @@
 
     baseConfig.setInt("index", null, "batchThreads", -1);
 
-    baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
+    Module auditModule = createAuditModule();
     Module sshModule = createSshModule();
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
       if (commonServer == null) {
         commonServer =
-            GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module, sshModule);
+            GerritServer.initAndStart(
+                temporaryFolder, classDesc, baseConfig, module, auditModule, sshModule);
       }
       server = commonServer;
     } else {
       server =
-          GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module, sshModule);
+          GerritServer.initAndStart(
+              temporaryFolder, methodDesc, baseConfig, module, auditModule, sshModule);
     }
 
     server.getTestInjector().injectMembers(this);
@@ -540,6 +542,11 @@
     return null;
   }
 
+  /** Override to bind an alternative audit Guice module */
+  public Module createAuditModule() {
+    return null;
+  }
+
   /** Override to bind an additional Guice module for SSH injector */
   public Module createSshModule() {
     return null;
@@ -819,6 +826,24 @@
   private static final List<Character> RANDOM =
       Chars.asList('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h');
 
+  protected PushOneCommit.Result amendChangeWithUploader(
+      PushOneCommit.Result change, Project.NameKey projectName, TestAccount account)
+      throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(projectName, account);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(change.getCommit());
+    PushOneCommit.Result result =
+        amendChange(
+            change.getChangeId(),
+            "refs/for/master",
+            account,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    return result;
+  }
+
   protected PushOneCommit.Result amendChange(String changeId) throws Exception {
     return amendChange(changeId, "refs/for/master", admin, testRepo);
   }
@@ -1000,7 +1025,7 @@
   protected void setUseSignedOffBy(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = projectConfigFactory.read(md);
-      config.getProject().setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, value);
+      config.updateProject(p -> p.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, value));
       config.commit(md);
       projectCache.evictAndReindex(config.getProject());
     }
@@ -1009,7 +1034,7 @@
   protected void setRequireChangeId(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = projectConfigFactory.read(md);
-      config.getProject().setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, value);
+      config.updateProject(p -> p.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, value));
       config.commit(md);
       projectCache.evictAndReindex(config.getProject());
     }
@@ -1230,11 +1255,15 @@
       String ref,
       boolean exclusive,
       String... permissionNames) {
-    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
-    AccessSection accessSection = cfg.getAccessSection(ref);
-    assertThat(accessSection).isNotNull();
+    Optional<AccessSection> accessSection =
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .getConfig()
+            .getAccessSection(ref);
+    assertThat(accessSection).isPresent();
     for (String permissionName : permissionNames) {
-      Permission permission = accessSection.getPermission(permissionName);
+      Permission permission = accessSection.get().getPermission(permissionName);
       assertPermission(permission, permissionName, exclusive, null);
       assertPermissionRule(
           permission.getRule(groupReference), groupReference, Action.ALLOW, false, 0, 0);
@@ -1274,7 +1303,7 @@
 
   protected GroupReference groupRef(AccountGroup.UUID groupUuid) {
     GroupDescription.Basic groupDescription = groupBackend.get(groupUuid);
-    return new GroupReference(groupDescription.getGroupUUID(), groupDescription.getName());
+    return GroupReference.create(groupDescription.getGroupUUID(), groupDescription.getName());
   }
 
   protected InternalGroup group(String groupName) {
@@ -1286,7 +1315,7 @@
   protected GroupReference groupRef(String groupName) {
     InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
     assertThat(group).isNotNull();
-    return new GroupReference(group.getGroupUUID(), group.getName());
+    return GroupReference.create(group.getGroupUUID(), group.getName());
   }
 
   protected AccountGroup.UUID groupUuid(String groupName) {
@@ -1382,6 +1411,7 @@
           pwi.filter = filter;
           pwi.notifyAbandonedChanges = true;
           pwi.notifyNewChanges = true;
+          pwi.notifyNewPatchSets = true;
           pwi.notifyAllComments = true;
         });
   }
@@ -1459,10 +1489,10 @@
       LabelValue... value)
       throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = label(label, value);
+      LabelType.Builder labelType = label(label, value).toBuilder();
       labelType.setFunction(func);
-      labelType.setRefPatterns(refPatterns);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      labelType.setRefPatterns(ImmutableList.copyOf(refPatterns));
+      u.getConfig().upsertLabelType(labelType.build());
       u.save();
     }
   }
@@ -1470,10 +1500,11 @@
   protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getProject()
-          .setBooleanConfig(
-              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
-              InheritableBoolean.TRUE);
+          .updateProject(
+              p ->
+                  p.setBooleanConfig(
+                      BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+                      InheritableBoolean.TRUE));
       u.save();
     }
   }
@@ -1525,7 +1556,8 @@
 
   protected List<CommentInfo> getChangeSortedComments(int changeNum) throws Exception {
     List<CommentInfo> comments = new ArrayList<>();
-    Map<String, List<CommentInfo>> commentsMap = gApi.changes().id(changeNum).comments();
+    Map<String, List<CommentInfo>> commentsMap =
+        gApi.changes().id(changeNum).commentsRequest().get();
     for (Map.Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
       for (CommentInfo c : e.getValue()) {
         c.path = e.getKey(); // Set the comment's path field.
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index bb3901e..452df67 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -28,6 +28,10 @@
 import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.EmailHeader.AddressList;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
@@ -36,10 +40,6 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
-import com.google.gerrit.mail.EmailHeader.AddressList;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index 020602b..a91bc49 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -21,26 +21,36 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.QueryChanges;
 import com.google.gerrit.sshd.commands.Query;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Module;
+import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import org.kohsuke.args4j.Option;
 
 public class AbstractPluginFieldsTest extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+
   protected static class MyInfo extends PluginDefinedInfo {
     @Nullable String theAttribute;
 
@@ -91,6 +101,70 @@
     }
   }
 
+  protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+                cds.forEach(cd -> out.put(cd.getId(), new MyInfo("change " + cd.getId())));
+                return out;
+              });
+    }
+  }
+
+  protected static class PluginDefinedBulkExceptionModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                throw new RuntimeException("Sample Exception");
+              });
+    }
+  }
+
+  protected static class PluginDefinedChangesByCommitBulkAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+                cds.forEach(
+                    cd ->
+                        out.put(
+                            cd.getId(),
+                            !cd.commitMessage().contains("no-info")
+                                ? new MyInfo("change " + cd.getId())
+                                : null));
+                return out;
+              });
+    }
+  }
+
+  protected static class PluginDefinedSingleCallBulkAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .to(SingleCallBulkFactoryAttribute.class);
+    }
+  }
+
+  protected static class SingleCallBulkFactoryAttribute implements ChangePluginDefinedInfoFactory {
+    public static int timesCreateCalled = 0;
+
+    @Override
+    public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+        Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
+      timesCreateCalled++;
+      Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+      cds.forEach(cd -> out.put(cd.getId(), new MyInfo("change " + cd.getId())));
+      return out;
+    }
+  }
+
   private static class MyOptions implements DynamicBean {
     @Option(name = "--opt")
     private String opt;
@@ -111,6 +185,32 @@
     }
   }
 
+  public static class BulkAttributeFactoryWithOption implements ChangePluginDefinedInfoFactory {
+    protected MyOptions opts;
+
+    @Override
+    public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+        Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
+      if (opts == null) {
+        opts = (MyOptions) beanProvider.getDynamicBean(plugin);
+      }
+      Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+      cds.forEach(cd -> out.put(cd.getId(), new MyInfo("opt " + opts.opt)));
+      return out;
+    }
+  }
+
+  protected static class PluginDefinedOptionAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .to(BulkAttributeFactoryWithOption.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
+    }
+  }
+
   protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
     Change.Id id = createChange().getChange().getId();
     assertThat(getter.call(id)).isNull();
@@ -138,6 +238,113 @@
     assertThat(getter.call(id)).isNull();
   }
 
+  protected void getSingleChangeWithPluginDefinedBulkAttribute(BulkPluginInfoGetterWithId getter)
+      throws Exception {
+    Change.Id id = createChange().getChange().getId();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call(id);
+    assertThat(pluginInfos.get(id)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+      pluginInfos = getter.call(id);
+      assertThat(pluginInfos.get(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
+    }
+
+    pluginInfos = getter.call(id);
+    assertThat(pluginInfos.get(id)).isNull();
+  }
+
+  protected void getMultipleChangesWithPluginDefinedBulkAttribute(BulkPluginInfoGetter getter)
+      throws Exception {
+    Change.Id id1 = createChange().getChange().getId();
+    Change.Id id2 = createChange().getChange().getId();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(id1)).containsExactly(new MyInfo("my-plugin", "change " + id1));
+      assertThat(pluginInfos.get(id2)).containsExactly(new MyInfo("my-plugin", "change " + id2));
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+  }
+
+  protected void getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+      BulkPluginInfoGetter getter) throws Exception {
+    Change.Id changeWithNoInfo = changeOperations.newChange().commitMessage("no-info").create();
+    Change.Id changeWithInfo = changeOperations.newChange().commitMessage("info").create();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+    assertThat(pluginInfos.get(changeWithInfo)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedChangesByCommitBulkAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+      assertThat(pluginInfos.get(changeWithInfo))
+          .containsExactly(new MyInfo("my-plugin", "change " + changeWithInfo));
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+    assertThat(pluginInfos.get(changeWithInfo)).isNull();
+  }
+
+  protected void getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+      BulkPluginInfoGetter getter) throws Exception {
+    Change.Id id1 = createChange().getChange().getId();
+    Change.Id id2 = createChange().getChange().getId();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+
+    try (AutoCloseable ignored =
+            installPlugin("my-plugin-1", PluginDefinedSimpleAttributeModule.class);
+        AutoCloseable ignored1 = installPlugin("my-plugin-2", SimpleAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-1", "change " + id1));
+      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-2", "change " + id1));
+      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-1", "change " + id2));
+      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-2", "change " + id2));
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+  }
+
+  protected void getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+      BulkPluginInfoGetter getter) throws Exception {
+    Change.Id id1 = createChange().getChange().getId();
+    Change.Id id2 = createChange().getChange().getId();
+    int timesCalled = SingleCallBulkFactoryAttribute.timesCreateCalled;
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSingleCallBulkAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(id1)).containsExactly(new MyInfo("my-plugin", "change " + id1));
+      assertThat(pluginInfos.get(id2)).containsExactly(new MyInfo("my-plugin", "change " + id2));
+      assertThat(SingleCallBulkFactoryAttribute.timesCreateCalled).isEqualTo(timesCalled + 1);
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+  }
+
   protected void getChangeWithOption(
       PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
       throws Exception {
@@ -154,17 +361,61 @@
     assertThat(getterWithoutOptions.call(id)).isNull();
   }
 
-  protected static List<MyInfo> pluginInfoFromSingletonList(List<ChangeInfo> changeInfos) {
+  protected void getChangeWithPluginDefinedBulkAttributeOption(
+      BulkPluginInfoGetterWithId getterWithoutOptions,
+      BulkPluginInfoGetterWithIdAndOptions getterWithOptions)
+      throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getterWithoutOptions.call(id).get(id)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedOptionAttributeModule.class)) {
+      assertThat(getterWithoutOptions.call(id).get(id))
+          .containsExactly(new MyInfo("my-plugin", "opt null"));
+      assertThat(
+              getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")).get(id))
+          .containsExactly(new MyInfo("my-plugin", "opt foo"));
+    }
+
+    assertThat(getterWithoutOptions.call(id).get(id)).isNull();
+  }
+
+  protected void getChangeWithPluginDefinedBulkAttributeWithException(
+      BulkPluginInfoGetterWithId getter) throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getter.call(id).get(id)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedBulkExceptionModule.class)) {
+      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
+      List<PluginDefinedInfo> outputInfos = getter.call(id).get(id);
+      assertThat(outputInfos).hasSize(1);
+      assertThat(outputInfos.get(0).name).isEqualTo("my-plugin");
+      assertThat(outputInfos.get(0).message).isEqualTo("Something went wrong in plugin: my-plugin");
+    }
+
+    assertThat(getter.call(id).get(id)).isNull();
+  }
+
+  protected static List<PluginDefinedInfo> pluginInfoFromSingletonList(
+      List<ChangeInfo> changeInfos) {
     assertThat(changeInfos).hasSize(1);
     return pluginInfoFromChangeInfo(changeInfos.get(0));
   }
 
-  protected static List<MyInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
+  protected static List<PluginDefinedInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
     List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
     if (pluginInfo == null) {
       return null;
     }
-    return pluginInfo.stream().map(MyInfo.class::cast).collect(toImmutableList());
+    return pluginInfo.stream().map(PluginDefinedInfo.class::cast).collect(toImmutableList());
+  }
+
+  protected static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromChangeInfos(
+      List<ChangeInfo> changeInfos) {
+    Map<Change.Id, List<PluginDefinedInfo>> out = new HashMap<>();
+    changeInfos.forEach(ci -> out.put(Change.id(ci._number), pluginInfoFromChangeInfo(ci)));
+    return out;
   }
 
   /**
@@ -180,7 +431,8 @@
    * @param plugins list of {@code MyInfo} objects, each as a raw map returned from Gson.
    * @return decoded list of {@code MyInfo}s.
    */
-  protected static List<MyInfo> decodeRawPluginsList(Gson gson, @Nullable Object plugins) {
+  protected static List<PluginDefinedInfo> decodeRawPluginsList(
+      Gson gson, @Nullable Object plugins) {
     if (plugins == null) {
       return null;
     }
@@ -188,14 +440,44 @@
     return gson.fromJson(gson.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
   }
 
+  protected static Map<Change.Id, List<PluginDefinedInfo>> getPluginInfosFromChangeInfos(
+      Gson gson, List<Map<String, Object>> changeInfos) {
+    Map<Change.Id, List<PluginDefinedInfo>> out = new HashMap<>();
+    changeInfos.forEach(
+        change -> {
+          Double changeId =
+              (Double)
+                  (change.get("number") != null ? change.get("number") : change.get("_number"));
+          out.put(
+              Change.id(changeId.intValue()), decodeRawPluginsList(gson, change.get("plugins")));
+        });
+    return out;
+  }
+
   @FunctionalInterface
   protected interface PluginInfoGetter {
-    List<MyInfo> call(Change.Id id) throws Exception;
+    List<PluginDefinedInfo> call(Change.Id id) throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface BulkPluginInfoGetter {
+    Map<Change.Id, List<PluginDefinedInfo>> call() throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface BulkPluginInfoGetterWithId {
+    Map<Change.Id, List<PluginDefinedInfo>> call(Change.Id id) throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface BulkPluginInfoGetterWithIdAndOptions {
+    Map<Change.Id, List<PluginDefinedInfo>> call(
+        Change.Id id, ImmutableListMultimap<String, String> pluginOptions) throws Exception;
   }
 
   @FunctionalInterface
   protected interface PluginInfoGetterWithOptions {
-    List<MyInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
+    List<PluginDefinedInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
         throws Exception;
   }
 }
diff --git a/java/com/google/gerrit/acceptance/AbstractPredicateTest.java b/java/com/google/gerrit/acceptance/AbstractPredicateTest.java
index c9fd3fb..c629959 100644
--- a/java/com/google/gerrit/acceptance/AbstractPredicateTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPredicateTest.java
@@ -41,10 +41,6 @@
   public static final String PLUGIN_NAME = "my-plugin";
   public static final Gson GSON = OutputFormat.JSON.newGson();
 
-  public static class MyInfo extends PluginDefinedInfo {
-    public String message;
-  }
-
   protected static class PluginModule extends AbstractModule {
     @Override
     public void configure() {
@@ -77,7 +73,7 @@
     public PluginDefinedInfo create(
         ChangeData cd, DynamicOptions.BeanProvider beanProvider, String plugin) {
       MyQueryOptions options = (MyQueryOptions) beanProvider.getDynamicBean(plugin);
-      MyInfo myInfo = new MyInfo();
+      PluginDefinedInfo myInfo = new PluginDefinedInfo();
       if (options.sample) {
         try {
           Predicate<ChangeData> predicate = queryBuilderProvider.get().parse("label:Code-Review+2");
@@ -94,11 +90,12 @@
     }
   }
 
-  protected static List<MyInfo> decodeRawPluginsList(@Nullable Object plugins) {
+  protected static List<PluginDefinedInfo> decodeRawPluginsList(@Nullable Object plugins) {
     if (plugins == null) {
       return Collections.emptyList();
     }
     checkArgument(plugins instanceof List, "not a list: %s", plugins);
-    return GSON.fromJson(GSON.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
+    return GSON.fromJson(
+        GSON.toJson(plugins), new TypeToken<List<PluginDefinedInfo>>() {}.getType());
   }
 }
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 44e2d2d..3ab1cec 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -101,6 +101,7 @@
                     .setPreferredEmail(email)
                     .addExternalIds(extIds));
 
+    ImmutableList.Builder<String> tags = ImmutableList.builder();
     if (groupNames != null) {
       for (String n : groupNames) {
         AccountGroup.NameKey k = AccountGroup.nameKey(n);
@@ -109,10 +110,14 @@
           throw new NoSuchGroupException(n);
         }
         addGroupMember(group.get().getGroupUUID(), id);
+        if ("Service Users".equals(n)) {
+          tags.add("SERVICE_USER");
+        }
       }
     }
 
-    account = TestAccount.create(id, username, email, fullName, displayName, httpPass);
+    account =
+        TestAccount.create(id, username, email, fullName, displayName, httpPass, tags.build());
     if (username != null) {
       accounts.put(username, account);
     }
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 9d8bc57..db0dc84 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -64,6 +64,7 @@
     "//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/data",
     "//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",
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index cfe7964..a5d8d19 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
+import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
@@ -79,6 +80,7 @@
   private final DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners;
   private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
   private final DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions;
+  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
 
   @Inject
   ExtensionRegistry(
@@ -107,7 +109,8 @@
       DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners,
       DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
       DynamicMap<CapabilityDefinition> capabilityDefinitions,
-      DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions) {
+      DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -134,6 +137,7 @@
     this.workInProgressStateChangedListeners = workInProgressStateChangedListeners;
     this.capabilityDefinitions = capabilityDefinitions;
     this.pluginProjectPermissionDefinitions = pluginProjectPermissionDefinitions;
+    this.pluginConfigEntries = pluginConfigEntries;
   }
 
   public Registration newRegistration() {
@@ -254,6 +258,10 @@
       return add(pluginProjectPermissionDefinitions, pluginProjectPermissionDefinition, exportName);
     }
 
+    public Registration add(ProjectConfigEntry pluginConfigEntry, String exportName) {
+      return add(pluginConfigEntries, pluginConfigEntry, exportName);
+    }
+
     private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
       return add(dynamicSet, extension, "gerrit");
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index f1598e7..cfc0ce4 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -30,6 +30,12 @@
 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.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerCommentOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerDraftCommentOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerPatchsetOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerRobotCommentOperationsImpl;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -327,6 +333,7 @@
       Description desc,
       Config baseConfig,
       @Nullable Module testSysModule,
+      @Nullable Module testAuditModule,
       @Nullable Module testSshModule)
       throws Exception {
     Path site = temporaryFolder.newFolder().toPath();
@@ -334,7 +341,7 @@
       if (!desc.memory()) {
         init(desc, baseConfig, site);
       }
-      return start(desc, baseConfig, site, testSysModule, testSshModule, null);
+      return start(desc, baseConfig, site, testSysModule, testAuditModule, testSshModule, null);
     } catch (Exception e) {
       throw e;
     }
@@ -363,6 +370,7 @@
       Config baseConfig,
       Path site,
       @Nullable Module testSysModule,
+      @Nullable Module testAuditModule,
       @Nullable Module testSshModule,
       @Nullable InMemoryRepositoryManager inMemoryRepoManager,
       String... additionalArgs)
@@ -382,7 +390,8 @@
             },
             site);
     daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
-    daemon.setAuditEventModuleForTesting(new FakeGroupAuditService.Module());
+    daemon.setAuditEventModuleForTesting(
+        MoreObjects.firstNonNull(testAuditModule, new FakeGroupAuditService.Module()));
     if (testSysModule != null) {
       daemon.addAdditionalSysModuleForTesting(testSysModule);
     }
@@ -492,7 +501,6 @@
     cfg.setString("gerrit", null, "basePath", "git");
     cfg.setBoolean("sendemail", null, "enable", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
-    cfg.setInt("cache", "projects", "checkFrequency", 0);
     cfg.setInt("plugins", null, "checkFrequency", 0);
 
     cfg.setInt("sshd", null, "threads", 1);
@@ -516,6 +524,11 @@
             bind(GroupOperations.class).to(GroupOperationsImpl.class);
             bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
             bind(RequestScopeOperations.class).to(RequestScopeOperationsImpl.class);
+            bind(ChangeOperations.class).to(ChangeOperationsImpl.class);
+            factory(PerPatchsetOperationsImpl.Factory.class);
+            factory(PerCommentOperationsImpl.Factory.class);
+            factory(PerDraftCommentOperationsImpl.Factory.class);
+            factory(PerRobotCommentOperationsImpl.Factory.class);
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
             install(new NoSshModule());
@@ -614,7 +627,7 @@
 
     server.close();
     server.daemon.stop();
-    return start(server.desc, cfg, site, null, null, inMemoryRepoManager);
+    return start(server.desc, cfg, site, null, null, null, inMemoryRepoManager);
   }
 
   public static GerritServer restart(
@@ -631,7 +644,7 @@
 
     server.close();
     server.daemon.stop();
-    return start(server.desc, cfg, site, testSysModule, testSshModule, inMemoryRepoManager);
+    return start(server.desc, cfg, site, testSysModule, null, testSshModule, inMemoryRepoManager);
   }
 
   private static boolean hasBinding(Injector injector, Class<?> clazz) {
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 3ccbe4d..afd451a 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -25,6 +25,8 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -91,6 +93,14 @@
         @Assisted String subject,
         @Assisted Map<String, String> files);
 
+    @UsedAt(Project.PLUGIN_CODE_OWNERS)
+    PushOneCommit create(
+        PersonIdent i,
+        TestRepository<?> testRepo,
+        @Assisted("subject") String subject,
+        @Assisted Map<String, String> files,
+        @Assisted("changeId") String changeId);
+
     PushOneCommit create(
         PersonIdent i,
         TestRepository<?> testRepo,
@@ -227,15 +237,16 @@
         changeId);
   }
 
-  private PushOneCommit(
+  @AssistedInject
+  PushOneCommit(
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
-      PersonIdent i,
-      TestRepository<?> testRepo,
-      String subject,
-      Map<String, String> files,
-      String changeId)
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
+      @Assisted("subject") String subject,
+      @Assisted Map<String, String> files,
+      @Nullable @Assisted("changeId") String changeId)
       throws Exception {
     this.testRepo = testRepo;
     this.notesFactory = notesFactory;
diff --git a/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
index b985e40..0b2282e 100644
--- a/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
+++ b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 6698657..21b216a 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -25,8 +25,12 @@
 import com.jcraft.jsch.JSch;
 import com.jcraft.jsch.KeyPair;
 import com.jcraft.jsch.Session;
+import java.io.IOException;
 import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
 import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
 import java.util.Scanner;
 
 public class SshSession {
@@ -81,6 +85,20 @@
     }
   }
 
+  public Reader execAndReturnReader(String command) throws Exception {
+    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
+    channel.setCommand(command);
+    channel.connect();
+
+    return new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8) {
+      @Override
+      public void close() throws IOException {
+        super.close();
+        channel.disconnect();
+      }
+    };
+  }
+
   private boolean hasError() {
     return error != null;
   }
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index 43fe4eb..dcb49a5 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -187,7 +187,14 @@
   private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
       throws Exception {
     return GerritServer.start(
-        serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, null, additionalArgs);
+        serverDesc,
+        baseConfig,
+        sitePaths.site_path,
+        testSysModule,
+        null,
+        null,
+        null,
+        additionalArgs);
   }
 
   protected static void runGerrit(String... args) throws Exception {
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index a7a4a89..d5908f4 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -22,7 +22,7 @@
 import com.google.common.net.InetAddresses;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import java.net.InetSocketAddress;
 import java.util.Arrays;
 import org.apache.http.client.utils.URIBuilder;
@@ -48,8 +48,10 @@
       @Nullable String email,
       @Nullable String fullName,
       @Nullable String displayName,
-      @Nullable String httpPassword) {
-    return new AutoValue_TestAccount(id, username, email, fullName, displayName, httpPassword);
+      @Nullable String httpPassword,
+      ImmutableList<String> tags) {
+    return new AutoValue_TestAccount(
+        id, username, email, fullName, displayName, httpPassword, tags);
   }
 
   public abstract Account.Id id();
@@ -69,6 +71,8 @@
   @Nullable
   public abstract String httpPassword();
 
+  public abstract ImmutableList<String> tags();
+
   public PersonIdent newIdent() {
     return new PersonIdent(fullName(), email());
   }
diff --git a/java/com/google/gerrit/acceptance/WaitUtil.java b/java/com/google/gerrit/acceptance/WaitUtil.java
new file mode 100644
index 0000000..6040f16
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/WaitUtil.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.acceptance;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.base.Stopwatch;
+import java.time.Duration;
+import java.util.function.Supplier;
+
+public class WaitUtil {
+  public static void waitUntil(Supplier<Boolean> waitCondition, Duration timeout)
+      throws InterruptedException {
+    Stopwatch stopwatch = Stopwatch.createStarted();
+    while (!waitCondition.get()) {
+      if (stopwatch.elapsed().compareTo(timeout) > 0) {
+        throw new InterruptedException();
+      }
+      MILLISECONDS.sleep(50);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/WaitUtilTest.java b/java/com/google/gerrit/acceptance/WaitUtilTest.java
new file mode 100644
index 0000000..565da9c
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/WaitUtilTest.java
@@ -0,0 +1,40 @@
+// 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;
+
+import static com.google.gerrit.acceptance.WaitUtil.waitUntil;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.time.Duration;
+import org.junit.Test;
+
+public class WaitUtilTest {
+
+  @Test
+  public void shouldFailWhenConditionNotMetWithinTimeout() throws Exception {
+    assertThrows(
+        InterruptedException.class,
+        () -> waitUntil(() -> returnTrue() == false, Duration.ofSeconds(1)));
+  }
+
+  @Test
+  public void shouldNotFailWhenConditionIsMetWithinTimeout() throws Exception {
+    waitUntil(() -> returnTrue() == true, Duration.ofSeconds(1));
+  }
+
+  private static boolean returnTrue() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index f64d7a2..8c1eebd 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -15,7 +15,10 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountState;
@@ -60,8 +63,8 @@
 
   private Account.Id createAccount(TestAccountCreation accountCreation) throws Exception {
     AccountsUpdate.AccountUpdater accountUpdater =
-        (account, updateBuilder) ->
-            fillBuilder(updateBuilder, accountCreation, account.account().id());
+        (accountState, updateBuilder) ->
+            fillBuilder(updateBuilder, accountCreation, accountState.account().id());
     AccountState createdAccount = createAccount(accountUpdater);
     return createdAccount.account().id();
   }
@@ -82,6 +85,11 @@
     accountCreation.username().ifPresent(u -> setUsername(builder, accountId, u, httpPassword));
     accountCreation.status().ifPresent(builder::setStatus);
     accountCreation.active().ifPresent(builder::setActive);
+    accountCreation
+        .secondaryEmails()
+        .forEach(
+            secondaryEmail ->
+                builder.addExternalId(ExternalId.createEmail(accountId, secondaryEmail)));
   }
 
   private static InternalAccountUpdate.Builder setPreferredEmail(
@@ -136,6 +144,7 @@
           .fullname(Optional.ofNullable(account.fullName()))
           .username(accountState.userName())
           .active(accountState.account().isActive())
+          .emails(ExternalId.getEmails(accountState.externalIds()).collect(toImmutableSet()))
           .build();
     }
 
@@ -147,7 +156,7 @@
     private void updateAccount(TestAccountUpdate accountUpdate)
         throws IOException, ConfigInvalidException {
       AccountsUpdate.AccountUpdater accountUpdater =
-          (account, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountId);
+          (accountState, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountState);
       Optional<AccountState> updatedAccount = updateAccount(accountUpdater);
       checkState(updatedAccount.isPresent(), "Tried to update non-existing test account");
     }
@@ -160,13 +169,58 @@
     private void fillBuilder(
         InternalAccountUpdate.Builder builder,
         TestAccountUpdate accountUpdate,
-        Account.Id accountId) {
+        AccountState accountState) {
       accountUpdate.fullname().ifPresent(builder::setFullName);
       accountUpdate.preferredEmail().ifPresent(e -> setPreferredEmail(builder, accountId, e));
       String httpPassword = accountUpdate.httpPassword().orElse(null);
       accountUpdate.username().ifPresent(u -> setUsername(builder, accountId, u, httpPassword));
       accountUpdate.status().ifPresent(builder::setStatus);
       accountUpdate.active().ifPresent(builder::setActive);
+
+      ImmutableSet<String> secondaryEmails = getSecondaryEmails(accountUpdate, accountState);
+      ImmutableSet<String> newSecondaryEmails =
+          ImmutableSet.copyOf(accountUpdate.secondaryEmailsModification().apply(secondaryEmails));
+      if (!secondaryEmails.equals(newSecondaryEmails)) {
+        setSecondaryEmails(builder, accountUpdate, accountState, newSecondaryEmails);
+      }
+    }
+
+    private ImmutableSet<String> getSecondaryEmails(
+        TestAccountUpdate accountUpdate, AccountState accountState) {
+      ImmutableSet<String> allEmails =
+          ExternalId.getEmails(accountState.externalIds()).collect(toImmutableSet());
+      if (accountUpdate.preferredEmail().isPresent()) {
+        return ImmutableSet.copyOf(
+            Sets.difference(allEmails, ImmutableSet.of(accountUpdate.preferredEmail().get())));
+      } else if (accountState.account().preferredEmail() != null) {
+        return ImmutableSet.copyOf(
+            Sets.difference(allEmails, ImmutableSet.of(accountState.account().preferredEmail())));
+      }
+      return allEmails;
+    }
+
+    private void setSecondaryEmails(
+        InternalAccountUpdate.Builder builder,
+        TestAccountUpdate accountUpdate,
+        AccountState accountState,
+        ImmutableSet<String> newSecondaryEmails) {
+      // delete all external IDs of SCHEME_MAILTO scheme, then add back SCHEME_MAILTO external IDs
+      // for the new secondary emails and the preferred email
+      builder.deleteExternalIds(
+          accountState.externalIds().stream()
+              .filter(e -> e.isScheme(ExternalId.SCHEME_MAILTO))
+              .collect(toImmutableSet()));
+      builder.addExternalIds(
+          newSecondaryEmails.stream()
+              .map(secondaryEmail -> ExternalId.createEmail(accountId, secondaryEmail))
+              .collect(toImmutableSet()));
+      if (accountUpdate.preferredEmail().isPresent()) {
+        builder.addExternalId(
+            ExternalId.createEmail(accountId, accountUpdate.preferredEmail().get()));
+      } else if (accountState.account().preferredEmail() != null) {
+        builder.addExternalId(
+            ExternalId.createEmail(accountId, accountState.account().preferredEmail()));
+      }
     }
 
     @Override
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
index 2574d55..94b1cc4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import java.util.Optional;
 
@@ -30,6 +32,16 @@
 
   public abstract boolean active();
 
+  public abstract ImmutableSet<String> emails();
+
+  public ImmutableSet<String> secondaryEmails() {
+    if (!preferredEmail().isPresent()) {
+      return emails();
+    }
+
+    return ImmutableSet.copyOf(Sets.difference(emails(), ImmutableSet.of(preferredEmail().get())));
+  }
+
   static Builder builder() {
     return new AutoValue_TestAccount.Builder();
   }
@@ -46,6 +58,8 @@
 
     abstract Builder active(boolean active);
 
+    abstract Builder emails(ImmutableSet<String> emails);
+
     abstract TestAccount build();
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
index 983fec0..042dc9a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.acceptance.testsuite.account;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import java.util.Optional;
+import java.util.Set;
 
 @AutoValue
 public abstract class TestAccountCreation {
@@ -33,6 +37,8 @@
 
   public abstract Optional<Boolean> active();
 
+  public abstract ImmutableSet<String> secondaryEmails();
+
   abstract ThrowingFunction<TestAccountCreation, Account.Id> accountCreator();
 
   public static Builder builder(ThrowingFunction<TestAccountCreation, Account.Id> accountCreator) {
@@ -83,14 +89,29 @@
       return active(false);
     }
 
+    public abstract Builder secondaryEmails(Set<String> secondaryEmails);
+
+    abstract ImmutableSet.Builder<String> secondaryEmailsBuilder();
+
+    public Builder addSecondaryEmail(String secondaryEmail) {
+      secondaryEmailsBuilder().add(secondaryEmail);
+      return this;
+    }
+
     abstract Builder accountCreator(
         ThrowingFunction<TestAccountCreation, Account.Id> accountCreator);
 
     abstract TestAccountCreation autoBuild();
 
     public Account.Id create() {
-      TestAccountCreation accountUpdate = autoBuild();
-      return accountUpdate.accountCreator().applyAndThrowSilently(accountUpdate);
+      TestAccountCreation accountCreation = autoBuild();
+      if (accountCreation.preferredEmail().isPresent()) {
+        checkState(
+            !accountCreation.secondaryEmails().contains(accountCreation.preferredEmail().get()),
+            "preferred email %s cannot be secondary email at the same time",
+            accountCreation.preferredEmail().get());
+      }
+      return accountCreation.accountCreator().applyAndThrowSilently(accountCreation);
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
index da599e7..46988eb 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
 
 @AutoValue
 public abstract class TestAccountUpdate {
@@ -32,11 +36,14 @@
 
   public abstract Optional<Boolean> active();
 
+  public abstract Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification();
+
   abstract ThrowingConsumer<TestAccountUpdate> accountUpdater();
 
   public static Builder builder(ThrowingConsumer<TestAccountUpdate> accountUpdater) {
     return new AutoValue_TestAccountUpdate.Builder()
         .accountUpdater(accountUpdater)
+        .secondaryEmailsModification(in -> in)
         .httpPassword("http-pass");
   }
 
@@ -82,6 +89,37 @@
       return active(false);
     }
 
+    abstract Builder secondaryEmailsModification(
+        Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification);
+
+    abstract Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification();
+
+    public Builder clearSecondaryEmails() {
+      return secondaryEmailsModification(originalSecondaryEmail -> ImmutableSet.of());
+    }
+
+    public Builder addSecondaryEmail(String secondaryEmail) {
+      Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification =
+          secondaryEmailsModification();
+      secondaryEmailsModification(
+          originalSecondaryEmails ->
+              Sets.union(
+                  secondaryEmailsModification.apply(originalSecondaryEmails),
+                  ImmutableSet.of(secondaryEmail)));
+      return this;
+    }
+
+    public Builder removeSecondaryEmail(String secondaryEmail) {
+      Function<ImmutableSet<String>, Set<String>> previousModification =
+          secondaryEmailsModification();
+      secondaryEmailsModification(
+          originalSecondaryEmails ->
+              Sets.difference(
+                  previousModification.apply(originalSecondaryEmails),
+                  ImmutableSet.of(secondaryEmail)));
+      return this;
+    }
+
     abstract Builder accountUpdater(ThrowingConsumer<TestAccountUpdate> accountUpdater);
 
     abstract TestAccountUpdate autoBuild();
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
new file mode 100644
index 0000000..c4e4192
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
@@ -0,0 +1,139 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+
+/**
+ * An aggregation of operations on changes for test purposes.
+ *
+ * <p>To execute the operations, no Gerrit permissions are necessary.
+ *
+ * <p><strong>Note:</strong> This interface is not implemented using the REST or extension API.
+ * Hence, it cannot be used for testing those APIs.
+ */
+public interface ChangeOperations {
+
+  /**
+   * Starts the fluent chain for querying or modifying a change. Please see the methods of {@link
+   * PerChangeOperations} for details on possible operations.
+   *
+   * @return an aggregation of operations on a specific change
+   */
+  PerChangeOperations change(Change.Id changeId);
+
+  /**
+   * Starts the fluent chain to create a change. The returned builder can be used to specify the
+   * attributes of the new change. To create the change for real, {@link
+   * TestChangeCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * Change.Id createdChangeId = changeOperations
+   *     .newChange()
+   *     .file("file1")
+   *     .content("Line 1\nLine2\n")
+   *     .create();
+   * </pre>
+   *
+   * <p><strong>Note:</strong> There must be at least one existing user and repository.
+   *
+   * @return a builder to create the new change
+   */
+  TestChangeCreation.Builder newChange();
+
+  /** An aggregation of methods on a specific change. */
+  interface PerChangeOperations {
+
+    /**
+     * Checks whether the change exists.
+     *
+     * @return {@code true} if the change exists
+     */
+    boolean exists();
+
+    /**
+     * Retrieves the change.
+     *
+     * <p><strong>Note:</strong> This call will fail with an exception if the requested change
+     * doesn't exist. If you want to check for the existence of a change, use {@link #exists()}
+     * instead.
+     *
+     * @return the corresponding {@code TestChange}
+     */
+    TestChange get();
+
+    /**
+     * Starts the fluent chain to create a new patchset. The returned builder can be used to specify
+     * the attributes of the new patchset. To create the patchset for real, {@link
+     * TestPatchsetCreation.Builder#create()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * PatchSet.Id createdPatchsetId = changeOperations
+     *     .change(changeId)
+     *     .newPatchset()
+     *     .file("file1")
+     *     .content("Line 1\nLine2\n")
+     *     .create();
+     * </pre>
+     *
+     * @return builder to create a new patchset
+     */
+    TestPatchsetCreation.Builder newPatchset();
+
+    /**
+     * Starts the fluent chain for querying or modifying a patchset. Please see the methods of
+     * {@link PerPatchsetOperations} for details on possible operations.
+     *
+     * @return an aggregation of operations on a specific patchset
+     */
+    PerPatchsetOperations patchset(PatchSet.Id patchsetId);
+
+    /**
+     * Like {@link #patchset(PatchSet.Id)} but for the current patchset.
+     *
+     * @return an aggregation of operations on a specific patchset
+     */
+    PerPatchsetOperations currentPatchset();
+
+    /**
+     * Starts the fluent chain for querying or modifying a published comment. Please see the methods
+     * of {@link PerCommentOperations} for details on possible operations.
+     *
+     * @return an aggregation of operations on a specific comment
+     */
+    PerCommentOperations comment(String commentUuid);
+
+    /**
+     * Starts the fluent chain for querying or modifying a draft comment. Please see the methods of
+     * {@link PerDraftCommentOperations} for details on possible operations.
+     *
+     * @return an aggregation of operations on a specific draft comment
+     */
+    PerDraftCommentOperations draftComment(String commentUuid);
+
+    /**
+     * Starts the fluent chain for querying or modifying a robot comment. Please see the methods of
+     * {@link PerRobotCommentOperations} for details on possible operations.
+     *
+     * @return an aggregation of operations on a specific robot comment
+     */
+    PerRobotCommentOperations robotComment(String commentUuid);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
new file mode 100644
index 0000000..3b15b57
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -0,0 +1,568 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.edit.tree.TreeCreator;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdate;
+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 java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+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.Repository;
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.Merger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+
+/**
+ * The implementation of {@link ChangeOperations}.
+ *
+ * <p>There is only one implementation of {@link ChangeOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class ChangeOperationsImpl implements ChangeOperations {
+  private final Sequences seq;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final PatchSetInserter.Factory patchsetInserterFactory;
+  private final GitRepositoryManager repositoryManager;
+  private final AccountResolver resolver;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final PersonIdent serverIdent;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ProjectCache projectCache;
+  private final ChangeFinder changeFinder;
+  private final PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory;
+  private final PerCommentOperationsImpl.Factory perCommentOperationsFactory;
+  private final PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory;
+  private final PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory;
+
+  @Inject
+  public ChangeOperationsImpl(
+      Sequences seq,
+      ChangeInserter.Factory changeInserterFactory,
+      PatchSetInserter.Factory patchsetInserterFactory,
+      GitRepositoryManager repositoryManager,
+      AccountResolver resolver,
+      IdentifiedUser.GenericFactory userFactory,
+      @GerritPersonIdent PersonIdent serverIdent,
+      BatchUpdate.Factory batchUpdateFactory,
+      ProjectCache projectCache,
+      ChangeFinder changeFinder,
+      PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory,
+      PerCommentOperationsImpl.Factory perCommentOperationsFactory,
+      PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory,
+      PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory) {
+    this.seq = seq;
+    this.changeInserterFactory = changeInserterFactory;
+    this.patchsetInserterFactory = patchsetInserterFactory;
+    this.repositoryManager = repositoryManager;
+    this.resolver = resolver;
+    this.userFactory = userFactory;
+    this.serverIdent = serverIdent;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.projectCache = projectCache;
+    this.changeFinder = changeFinder;
+    this.perPatchsetOperationsFactory = perPatchsetOperationsFactory;
+    this.perCommentOperationsFactory = perCommentOperationsFactory;
+    this.perDraftCommentOperationsFactory = perDraftCommentOperationsFactory;
+    this.perRobotCommentOperationsFactory = perRobotCommentOperationsFactory;
+  }
+
+  @Override
+  public PerChangeOperations change(Change.Id changeId) {
+    return new PerChangeOperationsImpl(changeId);
+  }
+
+  @Override
+  public TestChangeCreation.Builder newChange() {
+    return TestChangeCreation.builder(this::createChange);
+  }
+
+  private Change.Id createChange(TestChangeCreation changeCreation) throws Exception {
+    Change.Id changeId = Change.id(seq.nextChangeId());
+    Project.NameKey project = getTargetProject(changeCreation);
+
+    try (Repository repository = repositoryManager.openRepository(project);
+        ObjectInserter objectInserter = repository.newObjectInserter();
+        RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+      Timestamp now = TimeUtil.nowTs();
+      IdentifiedUser changeOwner = getChangeOwner(changeCreation);
+      PersonIdent authorAndCommitter =
+          changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+      ObjectId commitId =
+          createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
+
+      String refName = RefNames.fullName(changeCreation.branch());
+      ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+
+      try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+        batchUpdate.setRepository(repository, revWalk, objectInserter);
+        batchUpdate.insertChange(inserter);
+        batchUpdate.execute();
+      }
+      return changeId;
+    }
+  }
+
+  private Project.NameKey getTargetProject(TestChangeCreation changeCreation) {
+    if (changeCreation.project().isPresent()) {
+      return changeCreation.project().get();
+    }
+
+    return getArbitraryProject();
+  }
+
+  private Project.NameKey getArbitraryProject() {
+    Project.NameKey allProjectsName = projectCache.getAllProjects().getNameKey();
+    Project.NameKey allUsersName = projectCache.getAllUsers().getNameKey();
+    Optional<Project.NameKey> arbitraryProject =
+        projectCache.all().stream()
+            .filter(
+                name ->
+                    !Objects.equals(name, allProjectsName) && !Objects.equals(name, allUsersName))
+            .findFirst();
+    checkState(
+        arbitraryProject.isPresent(),
+        "At least one repository must be available on the Gerrit server");
+    return arbitraryProject.get();
+  }
+
+  private IdentifiedUser getChangeOwner(TestChangeCreation changeCreation)
+      throws IOException, ConfigInvalidException {
+    if (changeCreation.owner().isPresent()) {
+      return userFactory.create(changeCreation.owner().get());
+    }
+
+    return getArbitraryUser();
+  }
+
+  private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException {
+    ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet();
+    checkState(
+        !foundAccounts.isEmpty(),
+        "At least one user account must be available on the Gerrit server");
+    return userFactory.create(foundAccounts.iterator().next());
+  }
+
+  private ObjectId createCommit(
+      Repository repository,
+      RevWalk revWalk,
+      ObjectInserter objectInserter,
+      TestChangeCreation changeCreation,
+      PersonIdent authorAndCommitter)
+      throws IOException, BadRequestException {
+    ImmutableList<ObjectId> parentCommits = getParentCommits(repository, revWalk, changeCreation);
+    TreeCreator treeCreator =
+        getTreeCreator(objectInserter, parentCommits, changeCreation.mergeStrategy());
+    ObjectId tree = createNewTree(repository, treeCreator, changeCreation.treeModifications());
+    String commitMessage = correctCommitMessage(changeCreation.commitMessage());
+    return createCommit(
+        objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage);
+  }
+
+  private ImmutableList<ObjectId> getParentCommits(
+      Repository repository, RevWalk revWalk, TestChangeCreation changeCreation) {
+
+    return changeCreation
+        .parents()
+        .map(parents -> resolveParents(repository, revWalk, parents))
+        .orElseGet(() -> asImmutableList(getTip(repository, changeCreation.branch())));
+  }
+
+  private ImmutableList<ObjectId> resolveParents(
+      Repository repository, RevWalk revWalk, ImmutableList<TestCommitIdentifier> parents) {
+    return parents.stream()
+        .map(parent -> resolveCommit(repository, revWalk, parent))
+        .collect(toImmutableList());
+  }
+
+  private ObjectId resolveCommit(
+      Repository repository, RevWalk revWalk, TestCommitIdentifier parentCommit) {
+    switch (parentCommit.getKind()) {
+      case BRANCH:
+        return resolveBranchTip(repository, parentCommit.branch());
+      case CHANGE_ID:
+        return resolveChange(parentCommit.changeId());
+      case COMMIT_SHA_1:
+        return resolveCommitFromSha1(revWalk, parentCommit.commitSha1());
+      case PATCHSET_ID:
+        return resolvePatchset(parentCommit.patchsetId());
+      default:
+        throw new IllegalStateException(
+            String.format("No parent behavior implemented for %s.", parentCommit.getKind()));
+    }
+  }
+
+  private static ObjectId resolveBranchTip(Repository repository, String branchName) {
+    return getTip(repository, branchName)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Tip of branch %s not found and hence can't be used as parent.",
+                        branchName)));
+  }
+
+  private static Optional<ObjectId> getTip(Repository repository, String branch) {
+    try {
+      Optional<Ref> ref = Optional.ofNullable(repository.findRef(branch));
+      return ref.map(Ref::getObjectId);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private ObjectId resolveChange(Change.Id changeId) {
+    Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+    return changeNotes
+        .map(ChangeNotes::getCurrentPatchSet)
+        .map(PatchSet::commitId)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Change %s not found and hence can't be used as parent.", changeId)));
+  }
+
+  private static RevCommit resolveCommitFromSha1(RevWalk revWalk, ObjectId commitSha1) {
+    try {
+      return revWalk.parseCommit(commitSha1);
+    } catch (Exception e) {
+      throw new IllegalStateException(
+          String.format("Commit %s not found and hence can't be used as parent/base.", commitSha1),
+          e);
+    }
+  }
+
+  private ObjectId resolvePatchset(PatchSet.Id patchsetId) {
+    Optional<ChangeNotes> changeNotes = changeFinder.findOne(patchsetId.changeId());
+    return changeNotes
+        .map(ChangeNotes::getPatchSets)
+        .map(patchsets -> patchsets.get(patchsetId))
+        .map(PatchSet::commitId)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Patchset %s not found and hence can't be used as parent.", patchsetId)));
+  }
+
+  private static <T> ImmutableList<T> asImmutableList(Optional<T> value) {
+    return Streams.stream(value).collect(toImmutableList());
+  }
+
+  private static TreeCreator getTreeCreator(
+      RevWalk revWalk, ObjectId customBaseCommit, ImmutableList<ObjectId> parentCommits) {
+    RevCommit commit = resolveCommitFromSha1(revWalk, customBaseCommit);
+    // Use actual parents; relevant for example when a file is restored (->
+    // RestoreFileModification).
+    return TreeCreator.basedOnTree(commit.getTree(), parentCommits);
+  }
+
+  private static TreeCreator getTreeCreator(
+      ObjectInserter objectInserter,
+      ImmutableList<ObjectId> parentCommits,
+      MergeStrategy mergeStrategy) {
+    if (parentCommits.isEmpty()) {
+      return TreeCreator.basedOnEmptyTree();
+    }
+    ObjectId baseTreeId = merge(objectInserter, parentCommits, mergeStrategy);
+    return TreeCreator.basedOnTree(baseTreeId, parentCommits);
+  }
+
+  private static ObjectId merge(
+      ObjectInserter objectInserter,
+      ImmutableList<ObjectId> parentCommits,
+      MergeStrategy mergeStrategy) {
+    try {
+      Merger merger = mergeStrategy.newMerger(objectInserter, new Config());
+      boolean mergeSuccessful = merger.merge(parentCommits.toArray(new AnyObjectId[0]));
+      if (!mergeSuccessful) {
+        throw new IllegalStateException(
+            "Conflicts encountered while merging the specified parents. Use"
+                + " mergeOfButBaseOnFirst() instead to avoid these conflicts and define any"
+                + " other desired file contents with file().content().");
+      }
+      return merger.getResultTreeId();
+    } catch (IOException e) {
+      throw new IllegalStateException(
+          "Creating the merge commits of the specified parents failed for an unknown reason.", e);
+    }
+  }
+
+  private static ObjectId createNewTree(
+      Repository repository,
+      TreeCreator treeCreator,
+      ImmutableList<TreeModification> treeModifications)
+      throws IOException {
+    treeCreator.addTreeModifications(treeModifications);
+    return treeCreator.createNewTreeAndGetId(repository);
+  }
+
+  private String correctCommitMessage(String desiredCommitMessage) throws BadRequestException {
+    String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
+
+    if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
+      ObjectId id = CommitMessageUtil.generateChangeId();
+      commitMessage = ChangeIdUtil.insertId(commitMessage, id);
+    }
+
+    return commitMessage;
+  }
+
+  private ObjectId createCommit(
+      ObjectInserter objectInserter,
+      ObjectId tree,
+      ImmutableList<ObjectId> parentCommitIds,
+      PersonIdent author,
+      PersonIdent committer,
+      String commitMessage)
+      throws IOException {
+    CommitBuilder builder = new CommitBuilder();
+    builder.setTreeId(tree);
+    builder.setParentIds(parentCommitIds);
+    builder.setAuthor(author);
+    builder.setCommitter(committer);
+    builder.setMessage(commitMessage);
+    ObjectId newCommitId = objectInserter.insert(builder);
+    objectInserter.flush();
+    return newCommitId;
+  }
+
+  private ChangeInserter getChangeInserter(Change.Id changeId, String refName, ObjectId commitId) {
+    ChangeInserter inserter = changeInserterFactory.create(changeId, commitId, refName);
+    inserter.setMessage(String.format("Uploaded patchset %d.", inserter.getPatchSetId().get()));
+    return inserter;
+  }
+
+  private class PerChangeOperationsImpl implements PerChangeOperations {
+
+    private final Change.Id changeId;
+
+    public PerChangeOperationsImpl(Change.Id changeId) {
+      this.changeId = changeId;
+    }
+
+    @Override
+    public boolean exists() {
+      return changeFinder.findOne(changeId).isPresent();
+    }
+
+    @Override
+    public TestChange get() {
+      return toTestChange(getChangeNotes().getChange());
+    }
+
+    private ChangeNotes getChangeNotes() {
+      Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+      checkState(changeNotes.isPresent(), "Tried to get non-existing test change.");
+      return changeNotes.get();
+    }
+
+    private TestChange toTestChange(Change change) {
+      return TestChange.builder()
+          .numericChangeId(change.getId())
+          .changeId(change.getKey().get())
+          .build();
+    }
+
+    @Override
+    public TestPatchsetCreation.Builder newPatchset() {
+      return TestPatchsetCreation.builder(this::createPatchset);
+    }
+
+    private PatchSet.Id createPatchset(TestPatchsetCreation patchsetCreation)
+        throws IOException, RestApiException, UpdateException {
+      ChangeNotes changeNotes = getChangeNotes();
+      Project.NameKey project = changeNotes.getProjectName();
+      try (Repository repository = repositoryManager.openRepository(project);
+          ObjectInserter objectInserter = repository.newObjectInserter();
+          RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+        Timestamp now = TimeUtil.nowTs();
+        ObjectId newPatchsetCommit =
+            createPatchsetCommit(
+                repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
+
+        PatchSet.Id patchsetId =
+            ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
+        PatchSetInserter patchSetInserter =
+            getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
+
+        IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner());
+        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+          batchUpdate.setRepository(repository, revWalk, objectInserter);
+          batchUpdate.addOp(changeId, patchSetInserter);
+          batchUpdate.execute();
+        }
+        return patchsetId;
+      }
+    }
+
+    private ObjectId createPatchsetCommit(
+        Repository repository,
+        RevWalk revWalk,
+        ObjectInserter objectInserter,
+        ChangeNotes changeNotes,
+        TestPatchsetCreation patchsetCreation,
+        Timestamp now)
+        throws IOException, BadRequestException {
+      ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
+      RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
+
+      ImmutableList<ObjectId> parentCommitIds =
+          getParents(repository, revWalk, patchsetCreation, oldPatchsetCommit);
+      TreeCreator treeCreator = getTreeCreator(revWalk, oldPatchsetCommit, parentCommitIds);
+      ObjectId tree = createNewTree(repository, treeCreator, patchsetCreation.treeModifications());
+
+      String commitMessage =
+          correctCommitMessage(
+              changeNotes.getChange().getKey().get(),
+              patchsetCreation.commitMessage().orElseGet(oldPatchsetCommit::getFullMessage));
+
+      PersonIdent author = getAuthor(oldPatchsetCommit);
+      PersonIdent committer = getCommitter(oldPatchsetCommit, now);
+      return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
+    }
+
+    private String correctCommitMessage(String oldChangeId, String desiredCommitMessage)
+        throws BadRequestException {
+      String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
+
+      // Remove initial 'I' and treat the rest as ObjectId. This is not the cleanest approach but
+      // unfortunately, we don't seem to have other utility code which takes the string-based
+      // change-id and ensures that it is part of the commit message.
+      ObjectId id = ObjectId.fromString(oldChangeId.substring(1));
+      commitMessage = ChangeIdUtil.insertId(commitMessage, id, false);
+
+      return commitMessage;
+    }
+
+    private PersonIdent getAuthor(RevCommit oldPatchsetCommit) {
+      return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
+    }
+
+    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Timestamp now) {
+      PersonIdent oldPatchsetCommitter =
+          Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
+      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen())) {
+        /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
+         * In real situations, this automatically happens as two patchsets won't have exactly the
+         * same commit timestamp even when the tree and commit message are the same. In tests,
+         * we can easily end up with the same timestamp as Git uses second precision for timestamps.
+         * We could of course require that tests must use TestTimeUtil#setClockStep but
+         * that would be an unnecessary nuisance for test writers. Hence, go with a simple solution
+         * here and simply add a second. */
+        now = Timestamp.from(now.toInstant().plusSeconds(1));
+      }
+      return new PersonIdent(oldPatchsetCommitter, now);
+    }
+
+    private long asSeconds(Date date) {
+      return date.getTime() / 1000;
+    }
+
+    private ImmutableList<ObjectId> getParents(
+        Repository repository,
+        RevWalk revWalk,
+        TestPatchsetCreation patchsetCreation,
+        RevCommit oldPatchsetCommit) {
+      return patchsetCreation
+          .parents()
+          .map(parents -> resolveParents(repository, revWalk, parents))
+          .orElseGet(
+              () -> Arrays.stream(oldPatchsetCommit.getParents()).collect(toImmutableList()));
+    }
+
+    private PatchSetInserter getPatchSetInserter(
+        ChangeNotes changeNotes, ObjectId newPatchsetCommit, PatchSet.Id patchsetId) {
+      PatchSetInserter patchSetInserter =
+          patchsetInserterFactory.create(changeNotes, patchsetId, newPatchsetCommit);
+      patchSetInserter.setCheckAddPatchSetPermission(false);
+      patchSetInserter.setMessage(String.format("Uploaded patchset %d.", patchsetId.get()));
+      return patchSetInserter;
+    }
+
+    @Override
+    public PerPatchsetOperations patchset(PatchSet.Id patchsetId) {
+      return perPatchsetOperationsFactory.create(getChangeNotes(), patchsetId);
+    }
+
+    @Override
+    public PerPatchsetOperations currentPatchset() {
+      ChangeNotes changeNotes = getChangeNotes();
+      return perPatchsetOperationsFactory.create(
+          changeNotes, changeNotes.getChange().currentPatchSetId());
+    }
+
+    @Override
+    public PerCommentOperations comment(String commentUuid) {
+      ChangeNotes changeNotes = getChangeNotes();
+      return perCommentOperationsFactory.create(changeNotes, commentUuid);
+    }
+
+    @Override
+    public PerDraftCommentOperations draftComment(String commentUuid) {
+      ChangeNotes changeNotes = getChangeNotes();
+      return perDraftCommentOperationsFactory.create(changeNotes, commentUuid);
+    }
+
+    @Override
+    public PerRobotCommentOperations robotComment(String commentUuid) {
+      ChangeNotes changeNotes = getChangeNotes();
+      return perRobotCommentOperationsFactory.create(changeNotes, commentUuid);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/CommentSide.java b/java/com/google/gerrit/acceptance/testsuite/change/CommentSide.java
new file mode 100644
index 0000000..b7e720b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/CommentSide.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.acceptance.testsuite.change;
+
+/**
+ * Marks the commit that contains the comment (also known as side). Used by {@link
+ * TestCommentCreation} and {@link TestRobotCommentCreation}.
+ */
+enum CommentSide {
+  PATCHSET_COMMIT(1),
+  AUTO_MERGE_COMMIT(0),
+  PARENT_COMMIT(-1),
+  SECOND_PARENT_COMMIT(-2);
+
+  private final short numericSide;
+
+  CommentSide(int numericSide) {
+    this.numericSide = (short) numericSide;
+  }
+
+  public short getNumericSide() {
+    return numericSide;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileBuilder.java
new file mode 100644
index 0000000..c8514a7
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileBuilder.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.acceptance.testsuite.change;
+
+import java.util.function.Function;
+
+/**
+ * Builder for the file specification of line/range comments. Used by {@link TestCommentCreation}
+ * and {@link TestRobotCommentCreation}.
+ */
+public class FileBuilder<T> {
+  private final Function<String, T> nextStepProvider;
+
+  public FileBuilder(Function<String, T> nextStepProvider) {
+    this.nextStepProvider = nextStepProvider;
+  }
+  /** File on which the comment should be added. */
+  public T ofFile(String file) {
+    return nextStepProvider.apply(file);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
new file mode 100644
index 0000000..d0ccd5b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.DeleteFileModification;
+import com.google.gerrit.server.edit.tree.RenameFileModification;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.function.Consumer;
+
+/** Builder to simplify file content specification. */
+public class FileContentBuilder<T> {
+  private final T builder;
+  private final String filePath;
+  private final Consumer<TreeModification> modificationToBuilderAdder;
+
+  FileContentBuilder(
+      T builder, String filePath, Consumer<TreeModification> modificationToBuilderAdder) {
+    checkNotNull(Strings.emptyToNull(filePath), "File path must not be null or empty.");
+    this.builder = builder;
+    this.filePath = filePath;
+    this.modificationToBuilderAdder = modificationToBuilderAdder;
+  }
+
+  /** Content of the file. Must not be empty. */
+  public T content(String content) {
+    checkNotNull(
+        Strings.emptyToNull(content),
+        "Empty file content is not supported. Adjust test API if necessary.");
+    modificationToBuilderAdder.accept(
+        new ChangeFileContentModification(filePath, RawInputUtil.create(content)));
+    return builder;
+  }
+
+  /** Deletes the file. */
+  public T delete() {
+    modificationToBuilderAdder.accept(new DeleteFileModification(filePath));
+    return builder;
+  }
+
+  /**
+   * Renames the file while keeping its content.
+   *
+   * <p>If you want to both rename the file and adjust its content, delete the old path via {@link
+   * #delete()} and provide the desired content for the new path via {@link #content(String)}. If
+   * you use that approach, make sure to use a new content which is similar enough to the old (at
+   * least 60% line similarity) as otherwise Gerrit/Git won't identify it as a rename.
+   *
+   * <p>To create copied files, you need to go even one step further. Also rename the file you copy
+   * at the same time (-> delete old path + add two paths with the old content)! If you also want to
+   * adjust the content of the copy, you need to also slightly modify the content of the renamed
+   * file. Adjust the content of the copy slightly more if you want to control which file ends up as
+   * copy and which as rename (but keep the 60% line similarity threshold in mind).
+   *
+   * @param newFilePath new path of the file
+   */
+  public T renameTo(String newFilePath) {
+    modificationToBuilderAdder.accept(new RenameFileModification(filePath, newFilePath));
+    return builder;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/MultipleParentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/MultipleParentBuilder.java
new file mode 100644
index 0000000..63d8c0a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/MultipleParentBuilder.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.common.collect.ImmutableList;
+import java.util.function.Function;
+
+/** Builder to simplify specifying multiple parents for a change. */
+public class MultipleParentBuilder<T> {
+  private final Function<ImmutableList<TestCommitIdentifier>, T> parentsToBuilderAdder;
+  private final ImmutableList.Builder<TestCommitIdentifier> parents;
+
+  public MultipleParentBuilder(
+      Function<ImmutableList<TestCommitIdentifier>, T> parentsToBuilderAdder,
+      TestCommitIdentifier firstParent) {
+    this.parentsToBuilderAdder = parentsToBuilderAdder;
+    parents = ImmutableList.builder();
+    parents.add(firstParent);
+  }
+
+  /** Adds an intermediate parent. */
+  public ParentBuilder<MultipleParentBuilder<T>> followedBy() {
+    return new ParentBuilder<>(
+        parent -> {
+          parents.add(parent);
+          return this;
+        });
+  }
+
+  /** Adds the last parent. */
+  public ParentBuilder<T> and() {
+    return new ParentBuilder<>(
+        (parent) -> parentsToBuilderAdder.apply(parents.add(parent).build()));
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ParentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/ParentBuilder.java
new file mode 100644
index 0000000..b57aa6d
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ParentBuilder.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import java.util.function.Function;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Builder to simplify parent specification of a change. */
+public class ParentBuilder<T> {
+  private final Function<TestCommitIdentifier, T> parentToBuilderAdder;
+
+  public ParentBuilder(Function<TestCommitIdentifier, T> parentToBuilderAdder) {
+    this.parentToBuilderAdder = parentToBuilderAdder;
+  }
+
+  /** Use the commit identified by the specified SHA-1. */
+  public T commit(ObjectId commitSha1) {
+    return parentToBuilderAdder.apply(TestCommitIdentifier.ofCommitSha1(commitSha1));
+  }
+
+  /**
+   * Use the commit which is at the tip of the specified branch. Short branch names (without
+   * refs/heads) are automatically expanded.
+   */
+  public T tipOfBranch(String branchName) {
+    return parentToBuilderAdder.apply(TestCommitIdentifier.ofBranch(branchName));
+  }
+
+  /** Use the current patchset commit of the indicated change. */
+  public T change(Change.Id changeId) {
+    return parentToBuilderAdder.apply(TestCommitIdentifier.ofChangeId(changeId));
+  }
+
+  /** Use the commit identified by the specified patchset. */
+  public T patchset(PatchSet.Id patchsetId) {
+    return parentToBuilderAdder.apply(TestCommitIdentifier.ofPatchsetId(patchsetId));
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperations.java
new file mode 100644
index 0000000..aa2827c
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperations.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+/** An aggregation of methods on a specific, published comment. */
+public interface PerCommentOperations {
+
+  /**
+   * Retrieves the published comment.
+   *
+   * <p><strong>Note:</strong> This call will fail with an exception if the requested comment
+   * doesn't exist or if it is a comment of another type.
+   *
+   * @return the corresponding {@code TestComment}
+   */
+  TestHumanComment get();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperationsImpl.java
new file mode 100644
index 0000000..0218731
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperationsImpl.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import static com.google.common.collect.MoreCollectors.onlyElement;
+
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/**
+ * The implementation of {@link PerCommentOperations}.
+ *
+ * <p>There is only one implementation of {@link PerCommentOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class PerCommentOperationsImpl implements PerCommentOperations {
+  private final CommentsUtil commentsUtil;
+
+  private final ChangeNotes changeNotes;
+  private final String commentUuid;
+
+  public interface Factory {
+    PerCommentOperationsImpl create(ChangeNotes changeNotes, String commentUuid);
+  }
+
+  @Inject
+  public PerCommentOperationsImpl(
+      CommentsUtil commentsUtil, @Assisted ChangeNotes changeNotes, @Assisted String commentUuid) {
+    this.commentsUtil = commentsUtil;
+    this.changeNotes = changeNotes;
+    this.commentUuid = commentUuid;
+  }
+
+  @Override
+  public TestHumanComment get() {
+    HumanComment comment =
+        commentsUtil.publishedHumanCommentsByChange(changeNotes).stream()
+            .filter(foundComment -> foundComment.key.uuid.equals(commentUuid))
+            .collect(onlyElement());
+    return toTestComment(comment);
+  }
+
+  static TestHumanComment toTestComment(HumanComment comment) {
+    return TestHumanComment.builder()
+        .uuid(comment.key.uuid)
+        .parentUuid(comment.parentUuid)
+        .tag(comment.tag)
+        .unresolved(comment.unresolved)
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperations.java
new file mode 100644
index 0000000..cc1e844
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperations.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+/** An aggregation of methods on a specific draft comment. */
+public interface PerDraftCommentOperations {
+
+  /**
+   * Retrieves the draft comment.
+   *
+   * <p><strong>Note:</strong> This call will fail with an exception if the requested comment
+   * doesn't exist or if it is a comment of another type.
+   *
+   * @return the corresponding {@code TestComment}
+   */
+  TestHumanComment get();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java
new file mode 100644
index 0000000..db264c5
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.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.change;
+
+import static com.google.common.collect.MoreCollectors.onlyElement;
+import static com.google.gerrit.acceptance.testsuite.change.PerCommentOperationsImpl.toTestComment;
+
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/**
+ * The implementation of {@link PerDraftCommentOperationsImpl}.
+ *
+ * <p>There is only one implementation of {@link PerDraftCommentOperations}. Nevertheless, we keep
+ * the separation between interface and implementation to enhance clarity.
+ */
+public class PerDraftCommentOperationsImpl implements PerDraftCommentOperations {
+  private final CommentsUtil commentsUtil;
+
+  private final ChangeNotes changeNotes;
+  private final String commentUuid;
+
+  public interface Factory {
+    PerDraftCommentOperationsImpl create(ChangeNotes changeNotes, String commentUuid);
+  }
+
+  @Inject
+  public PerDraftCommentOperationsImpl(
+      CommentsUtil commentsUtil, @Assisted ChangeNotes changeNotes, @Assisted String commentUuid) {
+    this.commentsUtil = commentsUtil;
+    this.changeNotes = changeNotes;
+    this.commentUuid = commentUuid;
+  }
+
+  @Override
+  public TestHumanComment get() {
+    HumanComment comment =
+        commentsUtil.draftByChange(changeNotes).stream()
+            .filter(foundComment -> foundComment.key.uuid.equals(commentUuid))
+            .collect(onlyElement());
+    return toTestComment(comment);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
new file mode 100644
index 0000000..f4c70bd
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+/** An aggregation of methods on a specific patchset. */
+public interface PerPatchsetOperations {
+
+  /**
+   * Retrieves the patchset.
+   *
+   * <p><strong>Note:</strong> This call will fail with an exception if the requested patchset
+   * doesn't exist.
+   *
+   * @return the corresponding {@code TestPatchset}
+   */
+  TestPatchset get();
+
+  /**
+   * Starts the fluent chain to create a new, published comment. The returned builder can be used to
+   * specify the attributes of the comment. To create the comment for real, {@link
+   * TestCommentCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * String createdCommentUuid = changeOperations
+   *     .change(changeId)
+   *     .currentPatchset()
+   *     .newComment()
+   *     .onLine(2)
+   *     .ofFile("file1")
+   *     .create();
+   * </pre>
+   *
+   * @return builder to create a new comment
+   */
+  TestCommentCreation.Builder newComment();
+
+  /**
+   * Starts the fluent chain to create a new draft comment. The returned builder can be used to
+   * specify the attributes of the draft comment. To create the draft comment for real, {@link
+   * TestCommentCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * String createdDraftCommentUuid = changeOperations
+   *     .change(changeId)
+   *     .currentPatchset()
+   *     .newDraftComment()
+   *     .onLine(2)
+   *     .ofFile("file1")
+   *     .create();
+   * </pre>
+   *
+   * @return builder to create a new comment
+   */
+  TestCommentCreation.Builder newDraftComment();
+
+  /**
+   * Starts the fluent chain to create a new robot comment. The returned builder can be used to
+   * specify the attributes of the robot comment. To create the robot comment for real, {@link
+   * TestRobotCommentCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * String createdRobotCommentUuid = changeOperations
+   *     .change(changeId)
+   *     .currentPatchset()
+   *     .newRobotComment()
+   *     .onLine(2)
+   *     .ofFile("file1")
+   *     .create();
+   * </pre>
+   *
+   * @return builder to create a new comment
+   */
+  TestRobotCommentCreation.Builder newRobotComment();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
new file mode 100644
index 0000000..eda6c7e
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -0,0 +1,274 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment.Status;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+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.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * The implementation of {@link PerPatchsetOperations}.
+ *
+ * <p>There is only one implementation of {@link PerPatchsetOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class PerPatchsetOperationsImpl implements PerPatchsetOperations {
+  private final GitRepositoryManager repositoryManager;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final CommentsUtil commentsUtil;
+
+  private final ChangeNotes changeNotes;
+  private final PatchSet.Id patchsetId;
+
+  public interface Factory {
+    PerPatchsetOperationsImpl create(ChangeNotes changeNotes, PatchSet.Id patchsetId);
+  }
+
+  @Inject
+  private PerPatchsetOperationsImpl(
+      GitRepositoryManager repositoryManager,
+      GenericFactory userFactory,
+      BatchUpdate.Factory batchUpdateFactory,
+      CommentsUtil commentsUtil,
+      @Assisted ChangeNotes changeNotes,
+      @Assisted PatchSet.Id patchsetId) {
+    this.repositoryManager = repositoryManager;
+    this.userFactory = userFactory;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.commentsUtil = commentsUtil;
+    this.changeNotes = changeNotes;
+    this.patchsetId = patchsetId;
+  }
+
+  @Override
+  public TestPatchset get() {
+    PatchSet patchset = changeNotes.getPatchSets().get(patchsetId);
+    return TestPatchset.builder().patchsetId(patchsetId).commitId(patchset.commitId()).build();
+  }
+
+  @Override
+  public TestCommentCreation.Builder newComment() {
+    return TestCommentCreation.builder(this::createComment, Status.PUBLISHED);
+  }
+
+  @Override
+  public TestCommentCreation.Builder newDraftComment() {
+    return TestCommentCreation.builder(this::createComment, Status.DRAFT);
+  }
+
+  @Override
+  public TestRobotCommentCreation.Builder newRobotComment() {
+    return TestRobotCommentCreation.builder(this::createRobotComment);
+  }
+
+  private String createComment(TestCommentCreation commentCreation)
+      throws IOException, RestApiException, UpdateException {
+    Project.NameKey project = changeNotes.getProjectName();
+
+    try (Repository repository = repositoryManager.openRepository(project);
+        ObjectInserter objectInserter = repository.newObjectInserter();
+        RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+      Timestamp now = TimeUtil.nowTs();
+
+      IdentifiedUser author = getAuthor(commentCreation);
+      CommentAdditionOp commentAdditionOp = new CommentAdditionOp(commentCreation);
+      try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
+        batchUpdate.setRepository(repository, revWalk, objectInserter);
+        batchUpdate.addOp(changeNotes.getChangeId(), commentAdditionOp);
+        batchUpdate.execute();
+      }
+      return commentAdditionOp.createdCommentUuid;
+    }
+  }
+
+  private IdentifiedUser getAuthor(TestCommentCreation commentCreation) {
+    Account.Id authorId = commentCreation.author().orElse(changeNotes.getChange().getOwner());
+    return userFactory.create(authorId);
+  }
+
+  private IdentifiedUser getAuthor(TestRobotCommentCreation robotCommentCreation) {
+    Account.Id authorId = robotCommentCreation.author().orElse(changeNotes.getChange().getOwner());
+    return userFactory.create(authorId);
+  }
+
+  private static Comment.Range toCommentRange(TestRange range) {
+    Comment.Range commentRange = new Range();
+    commentRange.startLine = range.start().line();
+    commentRange.startCharacter = range.start().charOffset();
+    commentRange.endLine = range.end().line();
+    commentRange.endCharacter = range.end().charOffset();
+    return commentRange;
+  }
+
+  private class CommentAdditionOp implements BatchUpdateOp {
+    private String createdCommentUuid;
+    private final TestCommentCreation commentCreation;
+
+    public CommentAdditionOp(TestCommentCreation commentCreation) {
+      this.commentCreation = commentCreation;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext context) {
+      HumanComment comment = toNewComment(context, commentCreation);
+      ChangeUpdate changeUpdate = context.getUpdate(patchsetId);
+      changeUpdate.putComment(commentCreation.status(), comment);
+      // For published comments, only the tag set on the ChangeUpdate (and not on the HumanComment)
+      // matters.
+      commentCreation.tag().ifPresent(changeUpdate::setTag);
+      createdCommentUuid = comment.key.uuid;
+      return true;
+    }
+
+    private HumanComment toNewComment(ChangeContext context, TestCommentCreation commentCreation) {
+      String message = commentCreation.message().orElse("The text of a test comment.");
+
+      String filePath = commentCreation.file().orElse(Patch.PATCHSET_LEVEL);
+      short side = commentCreation.side().orElse(CommentSide.PATCHSET_COMMIT).getNumericSide();
+      Boolean unresolved = commentCreation.unresolved().orElse(null);
+      String parentUuid = commentCreation.parentUuid().orElse(null);
+      Timestamp createdOn =
+          commentCreation.createdOn().map(Timestamp::from).orElse(context.getWhen());
+      HumanComment newComment =
+          commentsUtil.newHumanComment(
+              context.getNotes(),
+              context.getUser(),
+              createdOn,
+              filePath,
+              patchsetId,
+              side,
+              message,
+              unresolved,
+              parentUuid);
+      // For draft comments, only the tag set on the HumanComment (and not on the ChangeUpdate)
+      // matters.
+      commentCreation.tag().ifPresent(tag -> newComment.tag = tag);
+
+      commentCreation.line().ifPresent(line -> newComment.setLineNbrAndRange(line, null));
+      // Specification of range trumps explicit line specification.
+      commentCreation
+          .range()
+          .map(PerPatchsetOperationsImpl::toCommentRange)
+          .ifPresent(range -> newComment.setLineNbrAndRange(null, range));
+
+      commentsUtil.setCommentCommitId(
+          newComment, context.getChange(), changeNotes.getPatchSets().get(patchsetId));
+      return newComment;
+    }
+  }
+
+  private String createRobotComment(TestRobotCommentCreation robotCommentCreation)
+      throws IOException, RestApiException, UpdateException {
+    Project.NameKey project = changeNotes.getProjectName();
+
+    try (Repository repository = repositoryManager.openRepository(project);
+        ObjectInserter objectInserter = repository.newObjectInserter();
+        RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+      Timestamp now = TimeUtil.nowTs();
+
+      IdentifiedUser author = getAuthor(robotCommentCreation);
+      RobotCommentAdditionOp robotCommentAdditionOp =
+          new RobotCommentAdditionOp(robotCommentCreation);
+      try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
+        batchUpdate.setRepository(repository, revWalk, objectInserter);
+        batchUpdate.addOp(changeNotes.getChangeId(), robotCommentAdditionOp);
+        batchUpdate.execute();
+      }
+      return robotCommentAdditionOp.createdRobotCommentUuid;
+    }
+  }
+
+  private class RobotCommentAdditionOp implements BatchUpdateOp {
+    private String createdRobotCommentUuid;
+    private final TestRobotCommentCreation robotCommentCreation;
+
+    public RobotCommentAdditionOp(TestRobotCommentCreation robotCommentCreation) {
+      this.robotCommentCreation = robotCommentCreation;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext context) {
+      RobotComment robotComment = toNewRobotComment(context, robotCommentCreation);
+      ChangeUpdate changeUpdate = context.getUpdate(patchsetId);
+      changeUpdate.putRobotComment(robotComment);
+      // For robot comments, only the tag set on the ChangeUpdate (and not on the RobotComment)
+      // matters.
+      robotCommentCreation.tag().ifPresent(changeUpdate::setTag);
+      createdRobotCommentUuid = robotComment.key.uuid;
+      return true;
+    }
+
+    private RobotComment toNewRobotComment(
+        ChangeContext context, TestRobotCommentCreation robotCommentCreation) {
+      String message = robotCommentCreation.message().orElse("The text of a test robot comment.");
+
+      String filePath = robotCommentCreation.file().orElse(Patch.PATCHSET_LEVEL);
+      short side = robotCommentCreation.side().orElse(CommentSide.PATCHSET_COMMIT).getNumericSide();
+      String robotId = robotCommentCreation.robotId().orElse("robot");
+      String robotRunId = robotCommentCreation.robotId().orElse("1");
+      RobotComment newRobotComment =
+          commentsUtil.newRobotComment(
+              context, filePath, patchsetId, side, message, robotId, robotRunId);
+
+      // TODO(paiking): This should not be needed, as the tag only matters in ChangeUpdate.
+      robotCommentCreation.tag().ifPresent(tag -> newRobotComment.tag = tag);
+
+      robotCommentCreation.line().ifPresent(line -> newRobotComment.setLineNbrAndRange(line, null));
+      // Specification of range trumps explicit line specification.
+      robotCommentCreation
+          .range()
+          .map(PerPatchsetOperationsImpl::toCommentRange)
+          .ifPresent(range -> newRobotComment.setLineNbrAndRange(null, range));
+
+      robotCommentCreation
+          .parentUuid()
+          .ifPresent(parentUuid -> newRobotComment.parentUuid = parentUuid);
+      robotCommentCreation.url().ifPresent(url -> newRobotComment.url = url);
+      if (!robotCommentCreation.properties().isEmpty()) {
+        newRobotComment.properties = robotCommentCreation.properties();
+      }
+
+      commentsUtil.setCommentCommitId(
+          newRobotComment, context.getChange(), changeNotes.getPatchSets().get(patchsetId));
+      return newRobotComment;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperations.java
new file mode 100644
index 0000000..c9718aa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperations.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+/** An aggregation of methods on a specific, robot comment. */
+public interface PerRobotCommentOperations {
+
+  /**
+   * Retrieves the robot comment.
+   *
+   * <p><strong>Note:</strong> This call will fail with an exception if the requested comment
+   * doesn't exist or if it is a comment of another type.
+   *
+   * @return the corresponding {@code TestRobotComment}
+   */
+  TestRobotComment get();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperationsImpl.java
new file mode 100644
index 0000000..075c451
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperationsImpl.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import static com.google.common.collect.MoreCollectors.onlyElement;
+
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/**
+ * The implementation of {@link PerRobotCommentOperations}.
+ *
+ * <p>There is only one implementation of {@link PerRobotCommentOperations}. Nevertheless, we keep
+ * the separation between interface and implementation to enhance clarity.
+ */
+public class PerRobotCommentOperationsImpl implements PerRobotCommentOperations {
+  private final CommentsUtil commentsUtil;
+
+  private final ChangeNotes changeNotes;
+  private final String commentUuid;
+
+  public interface Factory {
+    PerRobotCommentOperationsImpl create(ChangeNotes changeNotes, String commentUuid);
+  }
+
+  @Inject
+  public PerRobotCommentOperationsImpl(
+      CommentsUtil commentsUtil, @Assisted ChangeNotes changeNotes, @Assisted String commentUuid) {
+    this.commentsUtil = commentsUtil;
+    this.changeNotes = changeNotes;
+    this.commentUuid = commentUuid;
+  }
+
+  @Override
+  public TestRobotComment get() {
+    RobotComment comment =
+        commentsUtil.robotCommentsByChange(changeNotes).stream()
+            .filter(foundComment -> foundComment.key.uuid.equals(commentUuid))
+            .collect(onlyElement());
+    return toTestRobotComment(comment);
+  }
+
+  static TestRobotComment toTestRobotComment(RobotComment robotComment) {
+    return TestRobotComment.builder()
+        .uuid(robotComment.key.uuid)
+        .parentUuid(robotComment.parentUuid)
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PositionBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/PositionBuilder.java
new file mode 100644
index 0000000..b061c81
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PositionBuilder.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.testsuite.change;
+
+import java.util.function.IntFunction;
+
+/**
+ * Builder to simplify a position specification. Used by {@link TestCommentCreation} and {@link
+ * TestRobotCommentCreation}.
+ */
+public class PositionBuilder<T> {
+  private final IntFunction<T> nextStepProvider;
+
+  public PositionBuilder(IntFunction<T> nextStepProvider) {
+    this.nextStepProvider = nextStepProvider;
+  }
+
+  /** Character offset within the line. A value of 0 indicates the beginning of the line. */
+  public T charOffset(int characterOffset) {
+    return nextStepProvider.apply(characterOffset);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java
new file mode 100644
index 0000000..b9639f5
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.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.acceptance.testsuite.change;
+
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/**
+ * Builder for the end position of a range. Used by {@link TestCommentCreation} and {@link
+ * TestRobotCommentCreation}.
+ */
+public class StartAwarePositionBuilder<T> {
+  private final TestRange.Builder testRangeBuilder;
+  private final Consumer<TestRange> rangeConsumer;
+  private final Function<String, T> fileFunction;
+
+  public StartAwarePositionBuilder(
+      TestRange.Builder testRangeBuilder,
+      Consumer<TestRange> rangeConsumer,
+      Function<String, T> fileFunction) {
+    this.testRangeBuilder = testRangeBuilder;
+    this.rangeConsumer = rangeConsumer;
+    this.fileFunction = fileFunction;
+  }
+
+  /** Line of the end position of the range. */
+  public PositionBuilder<FileBuilder<T>> toLine(int endLine) {
+    return new PositionBuilder<>(
+        endCharOffset -> {
+          TestRange.Position end =
+              TestRange.Position.builder().line(endLine).charOffset(endCharOffset).build();
+          TestRange range = testRangeBuilder.setEnd(end).build();
+          rangeConsumer.accept(range);
+          return new FileBuilder<>(fileFunction);
+        });
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java
new file mode 100644
index 0000000..ea2acaa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+
+/** Representation of a change used for testing purposes. */
+@AutoValue
+public abstract class TestChange {
+
+  /**
+   * The numeric change ID, sometimes also called change number or legacy change ID. Unique per
+   * host.
+   */
+  public abstract Change.Id numericChangeId();
+
+  /**
+   * The Change-Id as specified in the commit message. Consists of an {@code I} followed by a 40-hex
+   * string. Only unique per project-branch.
+   */
+  public abstract String changeId();
+
+  static Builder builder() {
+    return new AutoValue_TestChange.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder numericChangeId(Change.Id numericChangeId);
+
+    abstract Builder changeId(String changeId);
+
+    abstract TestChange build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
new file mode 100644
index 0000000..5871e17
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.merge.MergeStrategy;
+
+/** Initial attributes of the change. If not provided, arbitrary values will be used. */
+@AutoValue
+public abstract class TestChangeCreation {
+  public abstract Optional<Project.NameKey> project();
+
+  public abstract String branch();
+
+  public abstract Optional<Account.Id> owner();
+
+  public abstract String commitMessage();
+
+  public abstract ImmutableList<TreeModification> treeModifications();
+
+  public abstract Optional<ImmutableList<TestCommitIdentifier>> parents();
+
+  public abstract MergeStrategy mergeStrategy();
+
+  abstract ThrowingFunction<TestChangeCreation, Change.Id> changeCreator();
+
+  public static Builder builder(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator) {
+    return new AutoValue_TestChangeCreation.Builder()
+        .changeCreator(changeCreator)
+        .branch(Constants.R_HEADS + Constants.MASTER)
+        .commitMessage("A test change")
+        // Which value we choose here doesn't matter. All relevant code paths set the desired value.
+        .mergeStrategy(MergeStrategy.OURS);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    /** Target project/Repository of the change. Must be an existing project. */
+    public abstract Builder project(Project.NameKey project);
+
+    /**
+     * Target branch of the change. Neither needs to exist nor needs to point to an actual commit.
+     */
+    public abstract Builder branch(String branch);
+
+    /** The change owner. Must be an existing user account. */
+    public abstract Builder owner(Account.Id owner);
+
+    /**
+     * The commit message. The message may contain a {@code Change-Id} footer but does not need to.
+     * If the footer is absent, it will be generated.
+     */
+    public abstract Builder commitMessage(String commitMessage);
+
+    /** Modified file of the change. The file content is specified via the returned builder. */
+    public FileContentBuilder<Builder> file(String filePath) {
+      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+    }
+
+    abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+    /**
+     * Parent commit of the change. The commit can be specified via various means in the returned
+     * builder.
+     */
+    public ParentBuilder<Builder> childOf() {
+      return new ParentBuilder<>(parentCommit -> parents(ImmutableList.of(parentCommit)));
+    }
+
+    /**
+     * Parent commits of the change. Each parent commit can be specified via various means in the
+     * returned builder. The order of the parents matters and is preserved (first parent commit in
+     * fluent change -> first parent of the change).
+     *
+     * <p>This method will automatically merge the parent commits and use the resulting commit as
+     * base for the change. Use {@link #file(String)} for additional file adjustments on top of that
+     * merge commit.
+     *
+     * <p><strong>Note:</strong> If this method fails with a merge conflict, use {@link
+     * #mergeOfButBaseOnFirst()} instead and specify all other necessary file contents manually via
+     * {@link #file(String)}.
+     */
+    public ParentBuilder<MultipleParentBuilder<Builder>> mergeOf() {
+      return new ParentBuilder<>(parent -> mergeBuilder(MergeStrategy.RECURSIVE, parent));
+    }
+
+    /**
+     * Parent commits of the change. Each parent commit can be specified via various means in the
+     * returned builder. The order of the parents matters and is preserved (first parent commit in
+     * fluent change -> first parent of the change).
+     *
+     * <p>This method will use the first specified parent commit as base for the resulting change.
+     * This approach is especially useful if merging the parents is not possible.
+     */
+    public ParentBuilder<MultipleParentBuilder<Builder>> mergeOfButBaseOnFirst() {
+      return new ParentBuilder<>(parent -> mergeBuilder(MergeStrategy.OURS, parent));
+    }
+
+    MultipleParentBuilder<Builder> mergeBuilder(
+        MergeStrategy mergeStrategy, TestCommitIdentifier parent) {
+      mergeStrategy(mergeStrategy);
+      return new MultipleParentBuilder<>(this::parents, parent);
+    }
+
+    abstract Builder parents(ImmutableList<TestCommitIdentifier> parents);
+
+    abstract Builder mergeStrategy(MergeStrategy mergeStrategy);
+
+    abstract Builder changeCreator(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator);
+
+    abstract TestChangeCreation autoBuild();
+
+    /**
+     * Creates the change.
+     *
+     * @return the {@code Change.Id} of the created change
+     */
+    public Change.Id create() {
+      TestChangeCreation changeUpdate = autoBuild();
+      return changeUpdate.changeCreator().applyAndThrowSilently(changeUpdate);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
new file mode 100644
index 0000000..2031bde
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
@@ -0,0 +1,219 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.acceptance.testsuite.change.TestRange.Position;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Patch;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Optional;
+
+/**
+ * Attributes of the human comment. If not provided, arbitrary values will be used. This class is
+ * very similar to {@link TestRobotCommentCreation} to allow separation between robot and human
+ * comments.
+ */
+@AutoValue
+public abstract class TestCommentCreation {
+
+  public abstract Optional<String> message();
+
+  public abstract Optional<String> file();
+
+  public abstract Optional<Integer> line();
+
+  public abstract Optional<TestRange> range();
+
+  public abstract Optional<CommentSide> side();
+
+  public abstract Optional<Boolean> unresolved();
+
+  public abstract Optional<String> parentUuid();
+
+  public abstract Optional<String> tag();
+
+  public abstract Optional<Account.Id> author();
+
+  public abstract Optional<Instant> createdOn();
+
+  abstract Comment.Status status();
+
+  abstract ThrowingFunction<TestCommentCreation, String> commentCreator();
+
+  public static Builder builder(
+      ThrowingFunction<TestCommentCreation, String> commentCreator, Comment.Status commentStatus) {
+    return new AutoValue_TestCommentCreation.Builder()
+        .commentCreator(commentCreator)
+        .status(commentStatus);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public Builder noMessage() {
+      return message("");
+    }
+
+    /** Message text of the comment. */
+    public abstract Builder message(String message);
+
+    /** Indicates a patchset-level comment. */
+    public Builder onPatchsetLevel() {
+      return file(Patch.PATCHSET_LEVEL);
+    }
+
+    /** Indicates a file comment. The comment will be on the specified file. */
+    public Builder onFileLevelOf(String filePath) {
+      return file(filePath).line(null).range(null);
+    }
+
+    /**
+     * Starts the fluent change to create a line comment. The line comment will be at the indicated
+     * line. Lines start with 1.
+     */
+    public FileBuilder<Builder> onLine(int line) {
+      return new FileBuilder<>(file -> file(file).line(line).range(null));
+    }
+
+    /**
+     * Starts the fluent chain to create a range comment. The range begins at the specified line.
+     * Lines start at 1. The start position (line, charOffset) is inclusive, the end position (line,
+     * charOffset) is exclusive.
+     */
+    public PositionBuilder<StartAwarePositionBuilder<Builder>> fromLine(int startLine) {
+      return new PositionBuilder<>(
+          startCharOffset -> {
+            Position start = Position.builder().line(startLine).charOffset(startCharOffset).build();
+            TestRange.Builder testRangeBuilder = TestRange.builder().setStart(start);
+            return new StartAwarePositionBuilder<>(testRangeBuilder, this::range, this::file);
+          });
+    }
+
+    /** File on which the comment should be added. */
+    abstract Builder file(String filePath);
+
+    /** Line on which the comment should be added. */
+    abstract Builder line(@Nullable Integer line);
+
+    /** Range on which the comment should be added. */
+    abstract Builder range(@Nullable TestRange range);
+
+    /**
+     * Indicates that the comment refers to a file, line, range, ... in the commit of the patchset.
+     *
+     * <p>On the UI, such comments are shown on the right side of a diff view when a diff against
+     * base is selected. See {@link #onParentCommit()} for comments shown on the left side.
+     */
+    public Builder onPatchsetCommit() {
+      return side(CommentSide.PATCHSET_COMMIT);
+    }
+
+    /**
+     * Indicates that the comment refers to a file, line, range, ... in the parent commit of the
+     * patchset.
+     *
+     * <p>On the UI, such comments are shown on the left side of a diff view when a diff against
+     * base is selected. See {@link #onPatchsetCommit()} for comments shown on the right side.
+     *
+     * <p>For merge commits, this indicates the first parent commit.
+     */
+    public Builder onParentCommit() {
+      return side(CommentSide.PARENT_COMMIT);
+    }
+
+    /** Like {@link #onParentCommit()} but for the second parent of a merge commit. */
+    public Builder onSecondParentCommit() {
+      return side(CommentSide.SECOND_PARENT_COMMIT);
+    }
+
+    /**
+     * Like {@link #onParentCommit()} but for the AutoMerge commit created from the parents of a
+     * merge commit.
+     */
+    public Builder onAutoMergeCommit() {
+      return side(CommentSide.AUTO_MERGE_COMMIT);
+    }
+
+    abstract Builder side(CommentSide side);
+
+    /** Indicates a resolved comment. */
+    public Builder resolved() {
+      return unresolved(false);
+    }
+
+    /** Indicates an unresolved comment. */
+    public Builder unresolved() {
+      return unresolved(true);
+    }
+
+    abstract Builder unresolved(boolean unresolved);
+
+    /**
+     * UUID of another comment to which this comment is a reply. This comment must have similar
+     * attributes (e.g. file, line, side) as the parent comment. The parent comment must be a
+     * published comment.
+     */
+    public abstract Builder parentUuid(String parentUuid);
+
+    /** Tag to attach to the comment. */
+    public abstract Builder tag(String value);
+
+    /** Author of the comment. Must be an existing user account. */
+    public abstract Builder author(Account.Id accountId);
+
+    /**
+     * Creation time of the comment. Like {@link #createdOn(Instant)} but with an arbitrary, fixed
+     * time zone (-> deterministic test execution).
+     */
+    public Builder createdOn(LocalDateTime createdOn) {
+      // We don't care about the exact time zone in most tests, just that it's fixed so that tests
+      // are deterministic.
+      return createdOn(createdOn.atZone(ZoneOffset.UTC).toInstant());
+    }
+
+    /**
+     * Creation time of the comment. This may also lie in the past or future. Comments stored in
+     * NoteDb support only second precision.
+     */
+    public abstract Builder createdOn(Instant createdOn);
+
+    /**
+     * Status of the comment. Hidden in the API surface. Use {@link
+     * PerPatchsetOperations#newComment()} or {@link PerPatchsetOperations#newDraftComment()}
+     * depending on which type of comment you want to create.
+     */
+    abstract Builder status(Comment.Status value);
+
+    abstract Builder commentCreator(ThrowingFunction<TestCommentCreation, String> commentCreator);
+
+    abstract TestCommentCreation autoBuild();
+
+    /**
+     * Creates the comment.
+     *
+     * @return the UUID of the created comment
+     */
+    public String create() {
+      TestCommentCreation commentCreation = autoBuild();
+      return commentCreation.commentCreator().applyAndThrowSilently(commentCreation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestCommitIdentifier.java b/java/com/google/gerrit/acceptance/testsuite/change/TestCommitIdentifier.java
new file mode 100644
index 0000000..a352607
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestCommitIdentifier.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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoOneOf;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Attributes, each one uniquely identifying a commit. */
+@AutoOneOf(TestCommitIdentifier.Kind.class)
+public abstract class TestCommitIdentifier {
+  public enum Kind {
+    COMMIT_SHA_1,
+    BRANCH,
+    CHANGE_ID,
+    PATCHSET_ID
+  }
+
+  public abstract Kind getKind();
+
+  /** SHA-1 of the commit. */
+  public abstract ObjectId commitSha1();
+
+  /** Branch whose tip points to the desired commit. */
+  public abstract String branch();
+
+  /** Numeric ID of the change whose current patchset points to the desired commit. */
+  public abstract Change.Id changeId();
+
+  /** ID of the patchset representing the desired commit. */
+  public abstract PatchSet.Id patchsetId();
+
+  public static TestCommitIdentifier ofCommitSha1(ObjectId commitSha1) {
+    return AutoOneOf_TestCommitIdentifier.commitSha1(commitSha1);
+  }
+
+  public static TestCommitIdentifier ofBranch(String branchName) {
+    return AutoOneOf_TestCommitIdentifier.branch(branchName);
+  }
+
+  public static TestCommitIdentifier ofChangeId(Change.Id changeId) {
+    return AutoOneOf_TestCommitIdentifier.changeId(changeId);
+  }
+
+  public static TestCommitIdentifier ofPatchsetId(PatchSet.Id patchsetId) {
+    return AutoOneOf_TestCommitIdentifier.patchsetId(patchsetId);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestHumanComment.java b/java/com/google/gerrit/acceptance/testsuite/change/TestHumanComment.java
new file mode 100644
index 0000000..3a7f2ae
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestHumanComment.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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
+/** Representation of a human comment used for testing purposes. */
+@AutoValue
+public abstract class TestHumanComment {
+
+  /** The UUID of the comment. Should be unique. */
+  public abstract String uuid();
+
+  /** UUID of another comment to which this comment is a reply. */
+  public abstract Optional<String> parentUuid();
+
+  /** Tag of a comment. */
+  public abstract Optional<String> tag();
+
+  /** Unresolved state of a comment. */
+  public abstract boolean unresolved();
+
+  static Builder builder() {
+    return new AutoValue_TestHumanComment.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder uuid(String uuid);
+
+    abstract Builder parentUuid(@Nullable String parentUuid);
+
+    abstract Builder tag(@Nullable String tag);
+
+    abstract Builder unresolved(boolean unresolved);
+
+    abstract TestHumanComment build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java
new file mode 100644
index 0000000..1ba242a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Representation of a patchset used for testing purposes. */
+@AutoValue
+public abstract class TestPatchset {
+
+  /** The numeric patchset ID. */
+  public abstract PatchSet.Id patchsetId();
+
+  /** The commit SHA-1 of the patchset. */
+  public abstract ObjectId commitId();
+
+  static Builder builder() {
+    return new AutoValue_TestPatchset.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder patchsetId(PatchSet.Id patchsetId);
+
+    abstract Builder commitId(ObjectId commitId);
+
+    abstract TestPatchset build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
new file mode 100644
index 0000000..fe9d909
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.Optional;
+
+/** Initial attributes of the patchset. If not provided, arbitrary values will be used. */
+@AutoValue
+public abstract class TestPatchsetCreation {
+
+  public abstract Optional<String> commitMessage();
+
+  public abstract ImmutableList<TreeModification> treeModifications();
+
+  public abstract Optional<ImmutableList<TestCommitIdentifier>> parents();
+
+  abstract ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator();
+
+  public static TestPatchsetCreation.Builder builder(
+      ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator) {
+    return new AutoValue_TestPatchsetCreation.Builder().patchsetCreator(patchsetCreator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder commitMessage(String commitMessage);
+
+    /** Modified file of the patchset. The file content is specified via the returned builder. */
+    public FileContentBuilder<Builder> file(String filePath) {
+      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+    }
+
+    abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+    /**
+     * Parent commit of the change. The commit can be specified via various means in the returned
+     * builder.
+     *
+     * <p>This method will just change the parent but not influence the contents of the patchset
+     * commit.
+     *
+     * <p>It's possible to switch from a change representing a merge commit to a change not being a
+     * merge commit with this method.
+     */
+    public ParentBuilder<Builder> parent() {
+      return new ParentBuilder<>(parent -> parents(ImmutableList.of(parent)));
+    }
+
+    /**
+     * Parent commits of the change. Each parent commit can be specified via various means in the
+     * returned builder. The order of the parents matters and is preserved (first parent commit in
+     * fluent change -> first parent of the change).
+     *
+     * <p>This method will just change the parents but not influence the contents of the patchset
+     * commit.
+     *
+     * <p>It's possible to switch from a change representing a non-merge commit to a change which is
+     * a merge commit with this method.
+     */
+    public ParentBuilder<MultipleParentBuilder<Builder>> parents() {
+      return new ParentBuilder<>(parent -> new MultipleParentBuilder<>(this::parents, parent));
+    }
+
+    abstract Builder parents(ImmutableList<TestCommitIdentifier> value);
+
+    abstract TestPatchsetCreation.Builder patchsetCreator(
+        ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator);
+
+    abstract TestPatchsetCreation autoBuild();
+
+    /**
+     * Creates the patchset.
+     *
+     * @return the {@code PatchSet.Id} of the created patchset
+     */
+    public PatchSet.Id create() {
+      TestPatchsetCreation patchsetCreation = autoBuild();
+      return patchsetCreation.patchsetCreator().applyAndThrowSilently(patchsetCreation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestRange.java b/java/com/google/gerrit/acceptance/testsuite/change/TestRange.java
new file mode 100644
index 0000000..f5cb7db
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestRange.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+
+/** Representation of a range used for testing purposes. */
+@AutoValue
+public abstract class TestRange {
+
+  /** Start position of the range. (inclusive) */
+  public abstract Position start();
+
+  /** End position of the range. (exclusive) */
+  public abstract Position end();
+
+  static Builder builder() {
+    return new AutoValue_TestRange.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+
+    abstract Builder setStart(Position start);
+
+    abstract Builder setEnd(Position end);
+
+    abstract TestRange build();
+  }
+
+  /** Position (start/end) of a range. */
+  @AutoValue
+  public abstract static class Position {
+
+    /** 1-based line. */
+    public abstract int line();
+
+    /** 0-based character offset within the line. */
+    public abstract int charOffset();
+
+    static Builder builder() {
+      return new AutoValue_TestRange_Position.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+
+      abstract Builder line(int line);
+
+      abstract Builder charOffset(int characterOffset);
+
+      abstract Position build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestRobotComment.java b/java/com/google/gerrit/acceptance/testsuite/change/TestRobotComment.java
new file mode 100644
index 0000000..76fb52f
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestRobotComment.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
+/** Representation of a robot comment used for testing purposes. */
+@AutoValue
+public abstract class TestRobotComment {
+
+  /** The UUID of the comment. Should be unique. */
+  public abstract String uuid();
+
+  /** UUID of another comment to which this comment is a reply. */
+  public abstract Optional<String> parentUuid();
+
+  static Builder builder() {
+    return new AutoValue_TestRobotComment.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder uuid(String uuid);
+
+    abstract Builder parentUuid(@Nullable String parentUuid);
+
+    abstract TestRobotComment build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestRobotCommentCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestRobotCommentCreation.java
new file mode 100644
index 0000000..558af3f
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestRobotCommentCreation.java
@@ -0,0 +1,200 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.acceptance.testsuite.change.TestRange.Position;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Patch;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Attributes of the robot comment. If not provided, arbitrary values will be used. This class is
+ * very similar to {@link TestCommentCreation} to allow separation between robot and human comments.
+ */
+@AutoValue
+public abstract class TestRobotCommentCreation {
+
+  public abstract Optional<String> message();
+
+  public abstract Optional<String> file();
+
+  public abstract Optional<Integer> line();
+
+  public abstract Optional<TestRange> range();
+
+  public abstract Optional<CommentSide> side();
+
+  public abstract Optional<String> parentUuid();
+
+  public abstract Optional<String> tag();
+
+  public abstract Optional<Account.Id> author();
+
+  public abstract Optional<String> robotId();
+
+  public abstract Optional<String> robotRunId();
+
+  public abstract Optional<String> url();
+
+  public abstract ImmutableMap<String, String> properties();
+
+  abstract ThrowingFunction<TestRobotCommentCreation, String> commentCreator();
+
+  public static Builder builder(ThrowingFunction<TestRobotCommentCreation, String> commentCreator) {
+    return new AutoValue_TestRobotCommentCreation.Builder().commentCreator(commentCreator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public Builder noMessage() {
+      return message("");
+    }
+
+    /** Message text of the comment. */
+    public abstract Builder message(String message);
+
+    /** Indicates a patchset-level comment. */
+    public Builder onPatchsetLevel() {
+      return file(Patch.PATCHSET_LEVEL);
+    }
+
+    /** Indicates a file comment. The comment will be on the specified file. */
+    public Builder onFileLevelOf(String filePath) {
+      return file(filePath).line(null).range(null);
+    }
+
+    /**
+     * Starts the fluent change to create a line comment. The line comment will be at the indicated
+     * line. Lines start with 1.
+     */
+    public FileBuilder<Builder> onLine(int line) {
+      return new FileBuilder<>(file -> file(file).line(line).range(null));
+    }
+
+    /**
+     * Starts the fluent chain to create a range comment. The range begins at the specified line.
+     * Lines start at 1. The start position (line, charOffset) is inclusive, the end position (line,
+     * charOffset) is exclusive.
+     */
+    public PositionBuilder<StartAwarePositionBuilder<Builder>> fromLine(int startLine) {
+      return new PositionBuilder<>(
+          startCharOffset -> {
+            Position start = Position.builder().line(startLine).charOffset(startCharOffset).build();
+            TestRange.Builder testRangeBuilder = TestRange.builder().setStart(start);
+            return new StartAwarePositionBuilder<>(testRangeBuilder, this::range, this::file);
+          });
+    }
+
+    /** File on which the comment should be added. */
+    abstract Builder file(String filePath);
+
+    /** Line on which the comment should be added. */
+    abstract Builder line(@Nullable Integer line);
+
+    /** Range on which the comment should be added. */
+    abstract Builder range(@Nullable TestRange range);
+
+    /**
+     * Indicates that the comment refers to a file, line, range, ... in the commit of the patchset.
+     *
+     * <p>On the UI, such comments are shown on the right side of a diff view when a diff against
+     * base is selected. See {@link #onParentCommit()} for comments shown on the left side.
+     */
+    public Builder onPatchsetCommit() {
+      return side(CommentSide.PATCHSET_COMMIT);
+    }
+
+    /**
+     * Indicates that the comment refers to a file, line, range, ... in the parent commit of the
+     * patchset.
+     *
+     * <p>On the UI, such comments are shown on the left side of a diff view when a diff against
+     * base is selected. See {@link #onPatchsetCommit()} for comments shown on the right side.
+     *
+     * <p>For merge commits, this indicates the first parent commit.
+     */
+    public Builder onParentCommit() {
+      return side(CommentSide.PARENT_COMMIT);
+    }
+
+    /** Like {@link #onParentCommit()} but for the second parent of a merge commit. */
+    public Builder onSecondParentCommit() {
+      return side(CommentSide.SECOND_PARENT_COMMIT);
+    }
+
+    /**
+     * Like {@link #onParentCommit()} but for the AutoMerge commit created from the parents of a
+     * merge commit.
+     */
+    public Builder onAutoMergeCommit() {
+      return side(CommentSide.AUTO_MERGE_COMMIT);
+    }
+
+    abstract Builder side(CommentSide side);
+
+    /**
+     * UUID of another comment to which this comment is a reply. This comment must have similar
+     * attributes (e.g. file, line, side) as the parent comment. The parent comment must be a
+     * published comment.
+     */
+    public abstract Builder parentUuid(String parentUuid);
+
+    /** Tag to attach to the comment. */
+    public abstract Builder tag(String value);
+
+    /** Author of the comment. Must be an existing user account. */
+    public abstract Builder author(Account.Id accountId);
+
+    /** Id of the robot that created the comment. */
+    public abstract Builder robotId(String robotId);
+
+    /** An ID of the run of the robot that created the comment. */
+    public abstract Builder robotRunId(String robotRunId);
+
+    /** Url for more information for the robot comment. */
+    public abstract Builder url(String url);
+
+    /** Robot specific properties as map that maps arbitrary keys to values. */
+    public abstract Builder properties(Map<String, String> properties);
+
+    abstract ImmutableMap.Builder<String, String> propertiesBuilder();
+
+    public Builder addProperty(String key, String value) {
+      propertiesBuilder().put(key, value);
+      return this;
+    }
+
+    abstract Builder commentCreator(
+        ThrowingFunction<TestRobotCommentCreation, String> commentCreator);
+
+    abstract TestRobotCommentCreation autoBuild();
+
+    /**
+     * Creates the robot comment.
+     *
+     * @return the UUID of the created robot comment
+     */
+    public String create() {
+      TestRobotCommentCreation commentCreation = autoBuild();
+      return commentCreation.commentCreator().applyAndThrowSilently(commentCreation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 59a2ed3..394f0f8 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.testsuite.project;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -26,11 +27,11 @@
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
-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.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -43,12 +44,10 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-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;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -92,7 +91,8 @@
     CreateProjectArgs args = new CreateProjectArgs();
     args.setProjectName(name);
     args.permissionsOnly = projectCreation.permissionOnly().orElse(false);
-    args.branch = Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
+    args.branch =
+        projectCreation.branches().stream().map(RefNames::fullName).collect(toImmutableList());
     args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
     projectCreation.parent().ifPresent(p -> args.newParent = p);
     // ProjectCreator wants non-null owner IDs.
@@ -155,51 +155,52 @@
         ProjectConfig projectConfig,
         ImmutableList<TestProjectUpdate.TestPermissionKey> removedPermissions) {
       for (TestProjectUpdate.TestPermissionKey p : removedPermissions) {
-        Permission permission =
-            projectConfig.getAccessSection(p.section(), true).getPermission(p.name(), true);
-        if (p.group().isPresent()) {
-          GroupReference group = new GroupReference(p.group().get(), p.group().get().get());
-          group = projectConfig.resolve(group);
-          permission.removeRule(group);
-        } else {
-          permission.clearRules();
-        }
+        projectConfig.upsertAccessSection(
+            p.section(),
+            as -> {
+              Permission.Builder permission = as.upsertPermission(p.name());
+              if (p.group().isPresent()) {
+                GroupReference group =
+                    GroupReference.create(p.group().get(), p.group().get().get());
+                group = projectConfig.resolve(group);
+                permission.removeRule(group);
+              } else {
+                permission.clearRules();
+              }
+            });
       }
     }
 
     private void addCapabilities(
         ProjectConfig projectConfig, ImmutableList<TestCapability> addedCapabilities) {
       for (TestCapability c : addedCapabilities) {
-        PermissionRule rule = newRule(projectConfig, c.group());
+        PermissionRule.Builder rule = newRule(projectConfig, c.group());
         rule.setRange(c.min(), c.max());
-        projectConfig
-            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-            .getPermission(c.name(), true)
-            .add(rule);
+        projectConfig.upsertAccessSection(
+            AccessSection.GLOBAL_CAPABILITIES, as -> as.upsertPermission(c.name()).add(rule));
       }
     }
 
     private void addPermissions(
         ProjectConfig projectConfig, ImmutableList<TestPermission> addedPermissions) {
       for (TestPermission p : addedPermissions) {
-        PermissionRule rule = newRule(projectConfig, p.group());
+        PermissionRule.Builder rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setForce(p.force());
-        projectConfig.getAccessSection(p.ref(), true).getPermission(p.name(), true).add(rule);
+        projectConfig.upsertAccessSection(p.ref(), as -> as.upsertPermission(p.name()).add(rule));
       }
     }
 
     private void addLabelPermissions(
         ProjectConfig projectConfig, ImmutableList<TestLabelPermission> addedLabelPermissions) {
       for (TestLabelPermission p : addedLabelPermissions) {
-        PermissionRule rule = newRule(projectConfig, p.group());
+        PermissionRule.Builder rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setRange(p.min(), p.max());
         String permissionName =
             p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
-        Permission permission =
-            projectConfig.getAccessSection(p.ref(), true).getPermission(permissionName, true);
-        permission.add(rule);
+        projectConfig.upsertAccessSection(
+            p.ref(), as -> as.upsertPermission(permissionName).add(rule));
       }
     }
 
@@ -208,16 +209,13 @@
         ImmutableMap<TestProjectUpdate.TestPermissionKey, Boolean> exclusiveGroupPermissions) {
       exclusiveGroupPermissions.forEach(
           (key, exclusive) ->
-              projectConfig
-                  .getAccessSection(key.section(), true)
-                  .getPermission(key.name(), true)
-                  .setExclusiveGroup(exclusive));
+              projectConfig.upsertAccessSection(
+                  key.section(),
+                  as -> as.upsertPermission(key.name()).setExclusiveGroup(exclusive)));
     }
 
     private RevCommit headOrNull(String branch) {
-      if (!branch.startsWith(Constants.R_REFS)) {
-        branch = RefNames.REFS_HEADS + branch;
-      }
+      branch = RefNames.fullName(branch);
 
       try (Repository repo = repoManager.openRepository(nameKey);
           RevWalk rw = new RevWalk(repo)) {
@@ -328,9 +326,10 @@
     }
   }
 
-  private static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
-    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
+  private static PermissionRule.Builder newRule(
+      ProjectConfig project, AccountGroup.UUID groupUUID) {
+    GroupReference group = GroupReference.create(groupUUID, groupUUID.get());
     group = project.resolve(group);
-    return new PermissionRule(group);
+    return PermissionRule.builder(group);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 3bbb8db..3337fc3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -18,11 +18,14 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 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;
+import java.util.Set;
+import org.eclipse.jgit.lib.Constants;
 
 @AutoValue
 public abstract class TestProjectCreation {
@@ -31,6 +34,8 @@
 
   public abstract Optional<Project.NameKey> parent();
 
+  public abstract ImmutableSet<String> branches();
+
   public abstract Optional<Boolean> createEmptyCommit();
 
   public abstract Optional<Boolean> permissionOnly();
@@ -43,7 +48,9 @@
 
   public static Builder builder(
       ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator) {
-    return new AutoValue_TestProjectCreation.Builder().projectCreator(projectCreator);
+    return new AutoValue_TestProjectCreation.Builder()
+        .branches(Constants.R_HEADS + Constants.MASTER)
+        .projectCreator(projectCreator);
   }
 
   @AutoValue.Builder
@@ -54,6 +61,17 @@
 
     public abstract TestProjectCreation.Builder submitType(SubmitType submitType);
 
+    /**
+     * Branches which should be created in the repository (with an empty root commit). The
+     * "refs/heads/" prefix of the branch name can be omitted. The specified branches are ignored if
+     * {@link #noEmptyCommit()} is used.
+     */
+    public TestProjectCreation.Builder branches(String branch1, String... otherBranches) {
+      return branches(Sets.union(ImmutableSet.of(branch1), ImmutableSet.copyOf(otherBranches)));
+    }
+
+    abstract TestProjectCreation.Builder branches(Set<String> branches);
+
     public abstract TestProjectCreation.Builder createEmptyCommit(boolean value);
 
     public abstract TestProjectCreation.Builder permissionOnly(boolean value);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index 739ed19..9a9a21a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.acceptance.testsuite.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.common.data.AccessSection.GLOBAL_CAPABILITIES;
+import static com.google.gerrit.entities.AccessSection.GLOBAL_CAPABILITIES;
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
@@ -23,11 +23,11 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/common/FooterConstants.java b/java/com/google/gerrit/common/FooterConstants.java
index 3ec809c..656d850 100644
--- a/java/com/google/gerrit/common/FooterConstants.java
+++ b/java/com/google/gerrit/common/FooterConstants.java
@@ -20,6 +20,9 @@
   /** The change ID as used to track patch sets. */
   public static final FooterKey CHANGE_ID = new FooterKey("Change-Id");
 
+  /** Link is an alternative footer that may be used to track patch sets. */
+  public static final FooterKey LINK = new FooterKey("Link");
+
   /** The footer telling us who reviewed the change. */
   public static final FooterKey REVIEWED_BY = new FooterKey("Reviewed-by");
 
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 085a601..3e103c8 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -37,10 +37,12 @@
     GOOGLE,
     COLLABNET,
     PLUGIN_CHECKS,
+    PLUGIN_CODE_OWNERS,
     PLUGIN_DELETE_PROJECT,
     PLUGIN_SERVICEUSER,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
+    PLUGIN_WEBSESSION_FLATFILE,
     PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
   }
 
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
deleted file mode 100644
index 0c9663b..0000000
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ /dev/null
@@ -1,189 +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.common.data;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Project;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Portion of a {@link Project} describing access rules. */
-public final class AccessSection implements Comparable<AccessSection> {
-  /** Special name given to the global capabilities; not a valid reference. */
-  public static final String GLOBAL_CAPABILITIES = "GLOBAL_CAPABILITIES";
-  /** Pattern that matches all references in a project. */
-  public static final String ALL = "refs/*";
-
-  /** Pattern that matches all branches in a project. */
-  public static final String HEADS = "refs/heads/*";
-
-  /** Prefix that triggers a regular expression pattern. */
-  public static final String REGEX_PREFIX = "^";
-
-  /** Name of the access section. It could be a ref pattern or something else. */
-  private String name;
-
-  private List<Permission> permissions;
-
-  public AccessSection(String name) {
-    this.name = name;
-  }
-
-  /** @return true if the name is likely to be a valid reference section name. */
-  public static boolean isValidRefSectionName(String name) {
-    return name.startsWith("refs/") || name.startsWith("^refs/");
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public ImmutableList<Permission> getPermissions() {
-    return permissions == null ? ImmutableList.of() : ImmutableList.copyOf(permissions);
-  }
-
-  public void setPermissions(List<Permission> list) {
-    requireNonNull(list);
-
-    Set<String> names = new HashSet<>();
-    for (Permission p : list) {
-      if (!names.add(p.getName().toLowerCase())) {
-        throw new IllegalArgumentException();
-      }
-    }
-
-    permissions = new ArrayList<>(list);
-  }
-
-  @Nullable
-  public Permission getPermission(String name) {
-    return getPermission(name, false);
-  }
-
-  @Nullable
-  public Permission getPermission(String name, boolean create) {
-    requireNonNull(name);
-
-    if (permissions != null) {
-      for (Permission p : permissions) {
-        if (p.getName().equalsIgnoreCase(name)) {
-          return p;
-        }
-      }
-    }
-
-    if (create) {
-      if (permissions == null) {
-        permissions = new ArrayList<>();
-      }
-
-      Permission p = new Permission(name);
-      permissions.add(p);
-      return p;
-    }
-
-    return null;
-  }
-
-  public void addPermission(Permission permission) {
-    requireNonNull(permission);
-
-    if (permissions == null) {
-      permissions = new ArrayList<>();
-    }
-
-    for (Permission p : permissions) {
-      if (p.getName().equalsIgnoreCase(permission.getName())) {
-        throw new IllegalArgumentException();
-      }
-    }
-
-    permissions.add(permission);
-  }
-
-  public void remove(Permission permission) {
-    requireNonNull(permission);
-    removePermission(permission.getName());
-  }
-
-  public void removePermission(String name) {
-    requireNonNull(name);
-
-    if (permissions != null) {
-      permissions.removeIf(permission -> name.equalsIgnoreCase(permission.getName()));
-    }
-  }
-
-  public void mergeFrom(AccessSection section) {
-    requireNonNull(section);
-
-    for (Permission src : section.getPermissions()) {
-      Permission dst = getPermission(src.getName());
-      if (dst != null) {
-        dst.mergeFrom(src);
-      } else {
-        permissions.add(src);
-      }
-    }
-  }
-
-  @Override
-  public int compareTo(AccessSection o) {
-    return comparePattern().compareTo(o.comparePattern());
-  }
-
-  private String comparePattern() {
-    if (getName().startsWith(REGEX_PREFIX)) {
-      return getName().substring(REGEX_PREFIX.length());
-    }
-    return getName();
-  }
-
-  @Override
-  public String toString() {
-    return "AccessSection[" + getName() + "]";
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof AccessSection)) {
-      return false;
-    }
-
-    AccessSection other = (AccessSection) obj;
-    if (!getName().equals(other.getName())) {
-      return false;
-    }
-    return new HashSet<>(getPermissions())
-        .equals(new HashSet<>(((AccessSection) obj).getPermissions()));
-  }
-
-  @Override
-  public int hashCode() {
-    int hashCode = super.hashCode();
-    if (permissions != null) {
-      for (Permission permission : permissions) {
-        hashCode += permission.hashCode();
-      }
-    }
-    hashCode += getName().hashCode();
-    return hashCode;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/CommentDetail.java b/java/com/google/gerrit/common/data/CommentDetail.java
index 55e0143..053764d 100644
--- a/java/com/google/gerrit/common/data/CommentDetail.java
+++ b/java/com/google/gerrit/common/data/CommentDetail.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import java.util.ArrayList;
 import java.util.List;
@@ -36,7 +37,7 @@
 
   protected CommentDetail() {}
 
-  public void include(Change.Id changeId, Comment p) {
+  public void include(Change.Id changeId, HumanComment p) {
     PatchSet.Id psId = PatchSet.id(changeId, p.key.patchSetId);
     if (p.side == 0) {
       if (idA == null && idB.equals(psId)) {
diff --git a/java/com/google/gerrit/common/data/ContributorAgreement.java b/java/com/google/gerrit/common/data/ContributorAgreement.java
deleted file mode 100644
index bc106f0..0000000
--- a/java/com/google/gerrit/common/data/ContributorAgreement.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// 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.common.data;
-
-import com.google.gerrit.entities.Project;
-import java.util.ArrayList;
-import java.util.List;
-
-/** Portion of a {@link Project} describing a single contributor agreement. */
-public class ContributorAgreement implements Comparable<ContributorAgreement> {
-  protected String name;
-  protected String description;
-  protected List<PermissionRule> accepted;
-  protected GroupReference autoVerify;
-  protected String agreementUrl;
-  protected List<String> excludeProjectsRegexes;
-  protected List<String> matchProjectsRegexes;
-
-  protected ContributorAgreement() {}
-
-  public ContributorAgreement(String name) {
-    setName(name);
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String name) {
-    this.name = name;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String description) {
-    this.description = description;
-  }
-
-  public List<PermissionRule> getAccepted() {
-    if (accepted == null) {
-      accepted = new ArrayList<>();
-    }
-    return accepted;
-  }
-
-  public void setAccepted(List<PermissionRule> accepted) {
-    this.accepted = accepted;
-  }
-
-  public GroupReference getAutoVerify() {
-    return autoVerify;
-  }
-
-  public void setAutoVerify(GroupReference autoVerify) {
-    this.autoVerify = autoVerify;
-  }
-
-  public String getAgreementUrl() {
-    return agreementUrl;
-  }
-
-  public void setAgreementUrl(String agreementUrl) {
-    this.agreementUrl = agreementUrl;
-  }
-
-  public List<String> getExcludeProjectsRegexes() {
-    if (excludeProjectsRegexes == null) {
-      excludeProjectsRegexes = new ArrayList<>();
-    }
-    return excludeProjectsRegexes;
-  }
-
-  public void setExcludeProjectsRegexes(List<String> excludeProjectsRegexes) {
-    this.excludeProjectsRegexes = excludeProjectsRegexes;
-  }
-
-  public List<String> getMatchProjectsRegexes() {
-    if (matchProjectsRegexes == null) {
-      matchProjectsRegexes = new ArrayList<>();
-    }
-    return matchProjectsRegexes;
-  }
-
-  public void setMatchProjectsRegexes(List<String> matchProjectsRegexes) {
-    this.matchProjectsRegexes = matchProjectsRegexes;
-  }
-
-  @Override
-  public int compareTo(ContributorAgreement o) {
-    return getName().compareTo(o.getName());
-  }
-
-  @Override
-  public String toString() {
-    return "ContributorAgreement[" + getName() + "]";
-  }
-}
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 10a66cc..51d9ecd 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
diff --git a/java/com/google/gerrit/common/data/GroupDescription.java b/java/com/google/gerrit/common/data/GroupDescription.java
deleted file mode 100644
index ed8b39d..0000000
--- a/java/com/google/gerrit/common/data/GroupDescription.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// 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.common.data;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
-import java.util.Set;
-
-/** Group methods exposed by the GroupBackend. */
-public class GroupDescription {
-  /** The Basic information required to be exposed by any Group. */
-  public interface Basic {
-    /** @return the non-null UUID of the group. */
-    AccountGroup.UUID getGroupUUID();
-
-    /** @return the non-null name of the group. */
-    String getName();
-
-    /**
-     * @return optional email address to send to the group's members. If provided, Gerrit will use
-     *     this email address to send change notifications to the group.
-     */
-    @Nullable
-    String getEmailAddress();
-
-    /**
-     * @return optional URL to information about the group. Typically a URL to a web page that
-     *     permits users to apply to join the group, or manage their membership.
-     */
-    @Nullable
-    String getUrl();
-  }
-
-  /** The extended information exposed by internal groups. */
-  public interface Internal extends Basic {
-
-    AccountGroup.Id getId();
-
-    @Nullable
-    String getDescription();
-
-    AccountGroup.UUID getOwnerGroupUUID();
-
-    boolean isVisibleToAll();
-
-    Timestamp getCreatedOn();
-
-    Set<Account.Id> getMembers();
-
-    Set<AccountGroup.UUID> getSubgroups();
-  }
-
-  private GroupDescription() {}
-}
diff --git a/java/com/google/gerrit/common/data/GroupReference.java b/java/com/google/gerrit/common/data/GroupReference.java
deleted file mode 100644
index 0af088e..0000000
--- a/java/com/google/gerrit/common/data/GroupReference.java
+++ /dev/null
@@ -1,120 +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.common.data;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.AccountGroup;
-
-/** Describes a group within a projects {@link AccessSection}s. */
-public class GroupReference implements Comparable<GroupReference> {
-
-  private static final String PREFIX = "group ";
-
-  public static GroupReference forGroup(GroupDescription.Basic group) {
-    return new GroupReference(group.getGroupUUID(), group.getName());
-  }
-
-  public static boolean isGroupReference(String configValue) {
-    return configValue != null && configValue.startsWith(PREFIX);
-  }
-
-  @Nullable
-  public static String extractGroupName(String configValue) {
-    if (!isGroupReference(configValue)) {
-      return null;
-    }
-    return configValue.substring(PREFIX.length()).trim();
-  }
-
-  protected String uuid;
-  protected String name;
-
-  protected GroupReference() {}
-
-  /**
-   * Create a group reference.
-   *
-   * @param uuid UUID of the group, must not be {@code null}
-   * @param name the group name, must not be {@code null}
-   */
-  public GroupReference(AccountGroup.UUID uuid, String name) {
-    setUUID(requireNonNull(uuid));
-    setName(name);
-  }
-
-  /**
-   * Create a group reference where the group's name couldn't be resolved.
-   *
-   * @param name the group name, must not be {@code null}
-   */
-  public GroupReference(String name) {
-    setUUID(null);
-    setName(name);
-  }
-
-  @Nullable
-  public AccountGroup.UUID getUUID() {
-    return uuid != null ? AccountGroup.uuid(uuid) : null;
-  }
-
-  public void setUUID(@Nullable AccountGroup.UUID newUUID) {
-    uuid = newUUID != null ? newUUID.get() : null;
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String newName) {
-    if (newName == null) {
-      throw new NullPointerException();
-    }
-    this.name = newName;
-  }
-
-  @Override
-  public int compareTo(GroupReference o) {
-    return uuid(this).compareTo(uuid(o));
-  }
-
-  private static String uuid(GroupReference a) {
-    if (a.getUUID() != null && a.getUUID().get() != null) {
-      return a.getUUID().get();
-    }
-
-    return "?";
-  }
-
-  @Override
-  public int hashCode() {
-    return uuid(this).hashCode();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return o instanceof GroupReference && compareTo((GroupReference) o) == 0;
-  }
-
-  public String toConfigValue() {
-    return PREFIX + name;
-  }
-
-  @Override
-  public String toString() {
-    return "Group[" + getName() + " / " + getUUID() + "]";
-  }
-}
diff --git a/java/com/google/gerrit/common/data/LabelFunction.java b/java/com/google/gerrit/common/data/LabelFunction.java
deleted file mode 100644
index 6af675b..0000000
--- a/java/com/google/gerrit/common/data/LabelFunction.java
+++ /dev/null
@@ -1,123 +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.common.data;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.PatchSetApproval;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Optional;
-
-/**
- * Functions for determining submittability based on label votes.
- *
- * <p>Only describes built-in label functions. Admins can extend the logic arbitrarily using Prolog
- * rules, in which case the choice of function in the project config is ignored.
- *
- * <p>Function semantics are documented in {@code config-labels.txt}, and actual behavior is
- * implemented both in Prolog in {@code gerrit_common.pl} and in the {@link #check} method.
- */
-public enum LabelFunction {
-  ANY_WITH_BLOCK("AnyWithBlock", true, false, false),
-  MAX_WITH_BLOCK("MaxWithBlock", true, true, true),
-  MAX_NO_BLOCK("MaxNoBlock", false, true, true),
-  NO_BLOCK("NoBlock"),
-  NO_OP("NoOp"),
-  PATCH_SET_LOCK("PatchSetLock");
-
-  public static final Map<String, LabelFunction> ALL;
-
-  static {
-    Map<String, LabelFunction> all = new LinkedHashMap<>();
-    for (LabelFunction f : values()) {
-      all.put(f.getFunctionName(), f);
-    }
-    ALL = Collections.unmodifiableMap(all);
-  }
-
-  public static Optional<LabelFunction> parse(@Nullable String str) {
-    return Optional.ofNullable(ALL.get(str));
-  }
-
-  private final String name;
-  private final boolean isBlock;
-  private final boolean isRequired;
-  private final boolean requiresMaxValue;
-
-  LabelFunction(String name) {
-    this(name, false, false, false);
-  }
-
-  LabelFunction(String name, boolean isBlock, boolean isRequired, boolean requiresMaxValue) {
-    this.name = name;
-    this.isBlock = isBlock;
-    this.isRequired = isRequired;
-    this.requiresMaxValue = requiresMaxValue;
-  }
-
-  /** The function name as defined in documentation and {@code project.config}. */
-  public String getFunctionName() {
-    return name;
-  }
-
-  /** Whether the label is a "block" label, meaning a minimum vote will prevent submission. */
-  public boolean isBlock() {
-    return isBlock;
-  }
-
-  /** Whether the label is a mandatory label, meaning absence of votes will prevent submission. */
-  public boolean isRequired() {
-    return isRequired;
-  }
-
-  /** Whether the label requires a vote with the maximum value to allow submission. */
-  public boolean isMaxValueRequired() {
-    return requiresMaxValue;
-  }
-
-  public SubmitRecord.Label check(LabelType labelType, Iterable<PatchSetApproval> approvals) {
-    SubmitRecord.Label submitRecordLabel = new SubmitRecord.Label();
-    submitRecordLabel.label = labelType.getName();
-
-    submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
-    if (isRequired) {
-      submitRecordLabel.status = SubmitRecord.Label.Status.NEED;
-    }
-
-    for (PatchSetApproval a : approvals) {
-      if (a.value() == 0) {
-        continue;
-      }
-
-      if (isBlock && labelType.isMaxNegative(a)) {
-        submitRecordLabel.appliedBy = a.accountId();
-        submitRecordLabel.status = SubmitRecord.Label.Status.REJECT;
-        return submitRecordLabel;
-      }
-
-      if (labelType.isMaxPositive(a) || !requiresMaxValue) {
-        submitRecordLabel.appliedBy = a.accountId();
-
-        submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
-        if (isRequired) {
-          submitRecordLabel.status = SubmitRecord.Label.Status.OK;
-        }
-      }
-    }
-
-    return submitRecordLabel;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
deleted file mode 100644
index 3a68414..0000000
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ /dev/null
@@ -1,353 +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.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;
-import java.util.Map;
-
-public class LabelType {
-  public static final boolean DEF_ALLOW_POST_SUBMIT = true;
-  public static final boolean DEF_CAN_OVERRIDE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
-  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) {
-    checkName(name);
-    List<LabelValue> values = new ArrayList<>(2);
-    values.add(new LabelValue((short) 0, "Rejected"));
-    values.add(new LabelValue((short) 1, "Approved"));
-    return new LabelType(name, values);
-  }
-
-  public static String checkName(String name) {
-    checkNameInternal(name);
-    if ("SUBM".equals(name)) {
-      throw new IllegalArgumentException("Reserved label name \"" + name + "\"");
-    }
-    return name;
-  }
-
-  public static String checkNameInternal(String name) {
-    if (name == null || name.isEmpty()) {
-      throw new IllegalArgumentException("Empty label name");
-    }
-    for (int i = 0; i < name.length(); i++) {
-      char c = name.charAt(i);
-      if ((i == 0 && c == '-')
-          || !((c >= 'a' && c <= 'z')
-              || (c >= 'A' && c <= 'Z')
-              || (c >= '0' && c <= '9')
-              || c == '-')) {
-        throw new IllegalArgumentException("Illegal label name \"" + name + "\"");
-      }
-    }
-    return name;
-  }
-
-  private static List<LabelValue> sortValues(List<LabelValue> values) {
-    values = new ArrayList<>(values);
-    if (values.isEmpty()) {
-      return Collections.emptyList();
-    }
-    values = values.stream().sorted(comparing(LabelValue::getValue)).collect(toList());
-    short v = values.get(0).getValue();
-    short i = 0;
-    ArrayList<LabelValue> result = new ArrayList<>();
-    // Fill in any missing values with empty text.
-    while (i < values.size()) {
-      while (v < values.get(i).getValue()) {
-        result.add(new LabelValue(v++, ""));
-      }
-      v++;
-      result.add(values.get(i++));
-    }
-    result.trimToSize();
-    return Collections.unmodifiableList(result);
-  }
-
-  protected String name;
-
-  protected LabelFunction function;
-
-  protected boolean copyAnyScore;
-  protected boolean copyMinScore;
-  protected boolean copyMaxScore;
-  protected boolean copyAllScoresOnMergeFirstParentUpdate;
-  protected boolean copyAllScoresOnTrivialRebase;
-  protected boolean copyAllScoresIfNoCodeChange;
-  protected boolean copyAllScoresIfNoChange;
-  protected ImmutableList<Short> copyValues;
-  protected boolean allowPostSubmit;
-  protected boolean ignoreSelfApproval;
-  protected short defaultValue;
-
-  protected List<LabelValue> values;
-  protected short maxNegative;
-  protected short maxPositive;
-
-  private transient boolean canOverride;
-  private transient List<String> refPatterns;
-  private transient Map<Short, LabelValue> byValue;
-
-  protected LabelType() {}
-
-  public LabelType(String name, List<LabelValue> valueList) {
-    this.name = checkName(name);
-    canOverride = true;
-    values = sortValues(valueList);
-    defaultValue = 0;
-
-    function = LabelFunction.MAX_WITH_BLOCK;
-
-    maxNegative = Short.MIN_VALUE;
-    maxPositive = Short.MAX_VALUE;
-    if (!values.isEmpty()) {
-      if (values.get(0).getValue() < 0) {
-        maxNegative = values.get(0).getValue();
-      }
-      if (values.get(values.size() - 1).getValue() > 0) {
-        maxPositive = values.get(values.size() - 1).getValue();
-      }
-    }
-    setCanOverride(DEF_CAN_OVERRIDE);
-    setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
-    setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-    setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-    setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-    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);
-
-    byValue = new HashMap<>();
-    for (LabelValue v : values) {
-      byValue.put(v.getValue(), v);
-    }
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String name) {
-    this.name = checkName(name);
-  }
-
-  public boolean matches(PatchSetApproval psa) {
-    return psa.labelId().get().equalsIgnoreCase(name);
-  }
-
-  public LabelFunction getFunction() {
-    return function;
-  }
-
-  public void setFunction(@Nullable LabelFunction function) {
-    this.function = function;
-  }
-
-  public boolean canOverride() {
-    return canOverride;
-  }
-
-  @Nullable
-  public List<String> getRefPatterns() {
-    return refPatterns;
-  }
-
-  public void setCanOverride(boolean canOverride) {
-    this.canOverride = canOverride;
-  }
-
-  public boolean allowPostSubmit() {
-    return allowPostSubmit;
-  }
-
-  public void setAllowPostSubmit(boolean allowPostSubmit) {
-    this.allowPostSubmit = allowPostSubmit;
-  }
-
-  public boolean ignoreSelfApproval() {
-    return ignoreSelfApproval;
-  }
-
-  public void setIgnoreSelfApproval(boolean ignoreSelfApproval) {
-    this.ignoreSelfApproval = ignoreSelfApproval;
-  }
-
-  public void setRefPatterns(List<String> refPatterns) {
-    if (refPatterns != null && !refPatterns.isEmpty()) {
-      this.refPatterns =
-          refPatterns.stream().collect(collectingAndThen(toList(), Collections::unmodifiableList));
-    } else {
-      this.refPatterns = null;
-    }
-  }
-
-  public List<LabelValue> getValues() {
-    return values;
-  }
-
-  public void setValues(List<LabelValue> values) {
-    this.values = sortValues(values);
-  }
-
-  public LabelValue getMin() {
-    if (values.isEmpty()) {
-      return null;
-    }
-    return values.get(0);
-  }
-
-  public LabelValue getMax() {
-    if (values.isEmpty()) {
-      return null;
-    }
-    return values.get(values.size() - 1);
-  }
-
-  public short getDefaultValue() {
-    return defaultValue;
-  }
-
-  public void setDefaultValue(short defaultValue) {
-    this.defaultValue = defaultValue;
-  }
-
-  public boolean isCopyAnyScore() {
-    return copyAnyScore;
-  }
-
-  public void setCopyAnyScore(boolean copyAnyScore) {
-    this.copyAnyScore = copyAnyScore;
-  }
-
-  public boolean isCopyMinScore() {
-    return copyMinScore;
-  }
-
-  public void setCopyMinScore(boolean copyMinScore) {
-    this.copyMinScore = copyMinScore;
-  }
-
-  public boolean isCopyMaxScore() {
-    return copyMaxScore;
-  }
-
-  public void setCopyMaxScore(boolean copyMaxScore) {
-    this.copyMaxScore = copyMaxScore;
-  }
-
-  public boolean isCopyAllScoresOnMergeFirstParentUpdate() {
-    return copyAllScoresOnMergeFirstParentUpdate;
-  }
-
-  public void setCopyAllScoresOnMergeFirstParentUpdate(
-      boolean copyAllScoresOnMergeFirstParentUpdate) {
-    this.copyAllScoresOnMergeFirstParentUpdate = copyAllScoresOnMergeFirstParentUpdate;
-  }
-
-  public boolean isCopyAllScoresOnTrivialRebase() {
-    return copyAllScoresOnTrivialRebase;
-  }
-
-  public void setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase) {
-    this.copyAllScoresOnTrivialRebase = copyAllScoresOnTrivialRebase;
-  }
-
-  public boolean isCopyAllScoresIfNoCodeChange() {
-    return copyAllScoresIfNoCodeChange;
-  }
-
-  public void setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange) {
-    this.copyAllScoresIfNoCodeChange = copyAllScoresIfNoCodeChange;
-  }
-
-  public boolean isCopyAllScoresIfNoChange() {
-    return copyAllScoresIfNoChange;
-  }
-
-  public void setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange) {
-    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();
-  }
-
-  public boolean isMaxPositive(PatchSetApproval ca) {
-    return maxPositive == ca.value();
-  }
-
-  public LabelValue getValue(short value) {
-    return byValue.get(value);
-  }
-
-  public LabelValue getValue(PatchSetApproval ca) {
-    return byValue.get(ca.value());
-  }
-
-  public LabelId getLabelId() {
-    return LabelId.create(name);
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb = new StringBuilder(name).append('[');
-    LabelValue min = getMin();
-    LabelValue max = getMax();
-    if (min != null && max != null) {
-      sb.append(
-          new PermissionRange(Permission.forLabel(name), min.getValue(), max.getValue())
-              .toString()
-              .trim());
-    } else if (min != null) {
-      sb.append(min.formatValue().trim());
-    } else if (max != null) {
-      sb.append(max.formatValue().trim());
-    }
-    sb.append(']');
-    return sb.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/LabelTypes.java b/java/com/google/gerrit/common/data/LabelTypes.java
deleted file mode 100644
index 1647658..0000000
--- a/java/com/google/gerrit/common/data/LabelTypes.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.entities.LabelId;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class LabelTypes {
-  protected List<LabelType> labelTypes;
-  private transient volatile Map<String, LabelType> byLabel;
-  private transient volatile Map<String, Integer> positions;
-
-  protected LabelTypes() {}
-
-  public LabelTypes(List<? extends LabelType> approvals) {
-    labelTypes = Collections.unmodifiableList(new ArrayList<>(approvals));
-  }
-
-  public List<LabelType> getLabelTypes() {
-    return labelTypes;
-  }
-
-  public LabelType byLabel(LabelId labelId) {
-    return byLabel().get(labelId.get().toLowerCase());
-  }
-
-  public LabelType byLabel(String labelName) {
-    return byLabel().get(labelName.toLowerCase());
-  }
-
-  private Map<String, LabelType> byLabel() {
-    if (byLabel == null) {
-      synchronized (this) {
-        if (byLabel == null) {
-          Map<String, LabelType> l = new HashMap<>();
-          if (labelTypes != null) {
-            for (LabelType t : labelTypes) {
-              l.put(t.getName().toLowerCase(), t);
-            }
-          }
-          byLabel = l;
-        }
-      }
-    }
-    return byLabel;
-  }
-
-  @Override
-  public String toString() {
-    return labelTypes.toString();
-  }
-
-  public Comparator<String> nameComparator() {
-    final Map<String, Integer> positions = positions();
-    return new Comparator<String>() {
-      @Override
-      public int compare(String left, String right) {
-        int lp = position(left);
-        int rp = position(right);
-        int cmp = lp - rp;
-        if (cmp == 0) {
-          cmp = left.compareTo(right);
-        }
-        return cmp;
-      }
-
-      private int position(String name) {
-        Integer p = positions.get(name);
-        return p != null ? p : positions.size();
-      }
-    };
-  }
-
-  private Map<String, Integer> positions() {
-    if (positions == null) {
-      synchronized (this) {
-        if (positions == null) {
-          Map<String, Integer> p = new HashMap<>();
-          if (labelTypes != null) {
-            int i = 0;
-            for (LabelType t : labelTypes) {
-              p.put(t.getName(), i++);
-            }
-          }
-          positions = p;
-        }
-      }
-    }
-    return positions;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/LabelValue.java b/java/com/google/gerrit/common/data/LabelValue.java
deleted file mode 100644
index c0ba781..0000000
--- a/java/com/google/gerrit/common/data/LabelValue.java
+++ /dev/null
@@ -1,78 +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.common.data;
-
-import java.util.Objects;
-
-public class LabelValue {
-  public static String formatValue(short value) {
-    if (value < 0) {
-      return Short.toString(value);
-    } else if (value == 0) {
-      return " 0";
-    } else {
-      return "+" + Short.toString(value);
-    }
-  }
-
-  protected short value;
-  protected String text;
-
-  public LabelValue(short value, String text) {
-    this.value = value;
-    this.text = text;
-  }
-
-  protected LabelValue() {}
-
-  public short getValue() {
-    return value;
-  }
-
-  public String getText() {
-    return text;
-  }
-
-  public String formatValue() {
-    return formatValue(value);
-  }
-
-  public String format() {
-    StringBuilder sb = new StringBuilder(formatValue());
-    if (!text.isEmpty()) {
-      sb.append(' ').append(text);
-    }
-    return sb.toString();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof LabelValue)) {
-      return false;
-    }
-    LabelValue v = (LabelValue) o;
-    return value == v.value && Objects.equals(text, v.text);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(value, text);
-  }
-
-  @Override
-  public String toString() {
-    return format();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
deleted file mode 100644
index 9b86b7e..0000000
--- a/java/com/google/gerrit/common/data/Permission.java
+++ /dev/null
@@ -1,302 +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.common.data;
-
-import com.google.common.collect.ImmutableList;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-
-/** A single permission within an {@link AccessSection} of a project. */
-public class Permission implements Comparable<Permission> {
-  public static final String ABANDON = "abandon";
-  public static final String ADD_PATCH_SET = "addPatchSet";
-  public static final String CREATE = "create";
-  public static final String CREATE_SIGNED_TAG = "createSignedTag";
-  public static final String CREATE_TAG = "createTag";
-  public static final String DELETE = "delete";
-  public static final String DELETE_CHANGES = "deleteChanges";
-  public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
-  public static final String EDIT_ASSIGNEE = "editAssignee";
-  public static final String EDIT_HASHTAGS = "editHashtags";
-  public static final String EDIT_TOPIC_NAME = "editTopicName";
-  public static final String FORGE_AUTHOR = "forgeAuthor";
-  public static final String FORGE_COMMITTER = "forgeCommitter";
-  public static final String FORGE_SERVER = "forgeServerAsCommitter";
-  public static final String LABEL = "label-";
-  public static final String LABEL_AS = "labelAs-";
-  public static final String OWNER = "owner";
-  public static final String PUSH = "push";
-  public static final String PUSH_MERGE = "pushMerge";
-  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";
-  public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
-
-  private static final List<String> NAMES_LC;
-  private static final int LABEL_INDEX;
-  private static final int LABEL_AS_INDEX;
-
-  static {
-    NAMES_LC = new ArrayList<>();
-    NAMES_LC.add(ABANDON.toLowerCase());
-    NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
-    NAMES_LC.add(CREATE.toLowerCase());
-    NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
-    NAMES_LC.add(CREATE_TAG.toLowerCase());
-    NAMES_LC.add(DELETE.toLowerCase());
-    NAMES_LC.add(DELETE_CHANGES.toLowerCase());
-    NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
-    NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
-    NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
-    NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
-    NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
-    NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
-    NAMES_LC.add(FORGE_SERVER.toLowerCase());
-    NAMES_LC.add(LABEL.toLowerCase());
-    NAMES_LC.add(LABEL_AS.toLowerCase());
-    NAMES_LC.add(OWNER.toLowerCase());
-    NAMES_LC.add(PUSH.toLowerCase());
-    NAMES_LC.add(PUSH_MERGE.toLowerCase());
-    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());
-    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
-
-    LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
-    LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
-  }
-
-  /** @return true if the name is recognized as a permission name. */
-  public static boolean isPermission(String varName) {
-    return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
-  }
-
-  public static boolean hasRange(String varName) {
-    return isLabel(varName) || isLabelAs(varName);
-  }
-
-  /** @return true if the permission name is actually for a review label. */
-  public static boolean isLabel(String varName) {
-    return varName.startsWith(LABEL) && LABEL.length() < varName.length();
-  }
-
-  /** @return true if the permission is for impersonated review labels. */
-  public static boolean isLabelAs(String var) {
-    return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
-  }
-
-  /** @return permission name for the given review label. */
-  public static String forLabel(String labelName) {
-    return LABEL + labelName;
-  }
-
-  /** @return permission name to apply a label for another user. */
-  public static String forLabelAs(String labelName) {
-    return LABEL_AS + labelName;
-  }
-
-  public static String extractLabel(String varName) {
-    if (isLabel(varName)) {
-      return varName.substring(LABEL.length());
-    } else if (isLabelAs(varName)) {
-      return varName.substring(LABEL_AS.length());
-    }
-    return null;
-  }
-
-  public static boolean canBeOnAllProjects(String ref, String permissionName) {
-    if (AccessSection.ALL.equals(ref)) {
-      return !OWNER.equals(permissionName);
-    }
-    return true;
-  }
-
-  protected String name;
-  protected boolean exclusiveGroup;
-  protected List<PermissionRule> rules;
-
-  protected Permission() {}
-
-  public Permission(String name) {
-    this.name = name;
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public String getLabel() {
-    return extractLabel(getName());
-  }
-
-  public boolean getExclusiveGroup() {
-    // Only permit exclusive group behavior on non OWNER permissions,
-    // otherwise an owner might lose access to a delegated subspace.
-    //
-    return exclusiveGroup && !OWNER.equals(getName());
-  }
-
-  public void setExclusiveGroup(boolean newExclusiveGroup) {
-    exclusiveGroup = newExclusiveGroup;
-  }
-
-  public ImmutableList<PermissionRule> getRules() {
-    return rules == null ? ImmutableList.of() : ImmutableList.copyOf(rules);
-  }
-
-  public void setRules(List<PermissionRule> list) {
-    rules = new ArrayList<>(list);
-  }
-
-  public void add(PermissionRule rule) {
-    initRules();
-    rules.add(rule);
-  }
-
-  public void remove(PermissionRule rule) {
-    if (rule != null) {
-      removeRule(rule.getGroup());
-    }
-  }
-
-  public void removeRule(GroupReference group) {
-    if (rules != null) {
-      rules.removeIf(permissionRule -> sameGroup(permissionRule, group));
-    }
-  }
-
-  public void clearRules() {
-    if (rules != null) {
-      rules.clear();
-    }
-  }
-
-  public PermissionRule getRule(GroupReference group) {
-    return getRule(group, false);
-  }
-
-  public PermissionRule getRule(GroupReference group, boolean create) {
-    initRules();
-
-    for (PermissionRule r : rules) {
-      if (sameGroup(r, group)) {
-        return r;
-      }
-    }
-
-    if (create) {
-      PermissionRule r = new PermissionRule(group);
-      rules.add(r);
-      return r;
-    }
-    return null;
-  }
-
-  void mergeFrom(Permission src) {
-    for (PermissionRule srcRule : src.getRules()) {
-      PermissionRule dstRule = getRule(srcRule.getGroup());
-      if (dstRule != null) {
-        dstRule.mergeFrom(srcRule);
-      } else {
-        add(srcRule);
-      }
-    }
-  }
-
-  private static boolean sameGroup(PermissionRule rule, GroupReference group) {
-    if (group.getUUID() != null) {
-      return group.getUUID().equals(rule.getGroup().getUUID());
-
-    } else if (group.getName() != null) {
-      return group.getName().equals(rule.getGroup().getName());
-
-    } else {
-      return false;
-    }
-  }
-
-  private void initRules() {
-    if (rules == null) {
-      rules = new ArrayList<>(4);
-    }
-  }
-
-  @Override
-  public int compareTo(Permission b) {
-    int cmp = index(this) - index(b);
-    if (cmp == 0) {
-      cmp = getName().compareTo(b.getName());
-    }
-    return cmp;
-  }
-
-  private static int index(Permission a) {
-    if (isLabel(a.getName())) {
-      return LABEL_INDEX;
-    } else if (isLabelAs(a.getName())) {
-      return LABEL_AS_INDEX;
-    }
-
-    int index = NAMES_LC.indexOf(a.getName().toLowerCase());
-    return 0 <= index ? index : NAMES_LC.size();
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof Permission)) {
-      return false;
-    }
-
-    final Permission other = (Permission) obj;
-    if (!name.equals(other.name) || exclusiveGroup != other.exclusiveGroup) {
-      return false;
-    }
-    return new HashSet<>(getRules()).equals(new HashSet<>(other.getRules()));
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder bldr = new StringBuilder();
-    bldr.append(name).append(" ");
-    if (exclusiveGroup) {
-      bldr.append("[exclusive] ");
-    }
-    bldr.append("[");
-    Iterator<PermissionRule> it = getRules().iterator();
-    while (it.hasNext()) {
-      bldr.append(it.next());
-      if (it.hasNext()) {
-        bldr.append(", ");
-      }
-    }
-    bldr.append("]");
-    return bldr.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/PermissionRange.java b/java/com/google/gerrit/common/data/PermissionRange.java
deleted file mode 100644
index 97c3731..0000000
--- a/java/com/google/gerrit/common/data/PermissionRange.java
+++ /dev/null
@@ -1,144 +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.common.data;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Represents a closed interval [min, max] with a name. The special value [0, 0] is understood to be
- * the empty range.
- */
-public class PermissionRange implements Comparable<PermissionRange> {
-  public static class WithDefaults extends PermissionRange {
-    protected int defaultMin;
-    protected int defaultMax;
-
-    protected WithDefaults() {}
-
-    public WithDefaults(String name, int min, int max, int defMin, int defMax) {
-      super(name, min, max);
-      setDefaultRange(defMin, defMax);
-    }
-
-    public int getDefaultMin() {
-      return defaultMin;
-    }
-
-    public int getDefaultMax() {
-      return defaultMax;
-    }
-
-    public void setDefaultRange(int min, int max) {
-      defaultMin = min;
-      defaultMax = max;
-    }
-
-    /** @return all values between {@link #getMin()} and {@link #getMax()} */
-    public List<Integer> getValuesAsList() {
-      ArrayList<Integer> r = new ArrayList<>(getRangeSize());
-      for (int i = min; i <= max; i++) {
-        r.add(i);
-      }
-      return r;
-    }
-
-    /** @return number of values between {@link #getMin()} and {@link #getMax()} */
-    public int getRangeSize() {
-      return max - min;
-    }
-  }
-
-  protected String name;
-  protected int min;
-  protected int max;
-
-  protected PermissionRange() {}
-
-  public PermissionRange(String name, int min, int max) {
-    this.name = name;
-
-    if (min <= max) {
-      this.min = min;
-      this.max = max;
-    } else {
-      this.min = 0;
-      this.max = 0;
-    }
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public boolean isLabel() {
-    return Permission.isLabel(getName());
-  }
-
-  public String getLabel() {
-    return Permission.extractLabel(getName());
-  }
-
-  public int getMin() {
-    return min;
-  }
-
-  public int getMax() {
-    return max;
-  }
-
-  /** True if the value is within the range. */
-  public boolean contains(int value) {
-    return getMin() <= value && value <= getMax();
-  }
-
-  /** Normalize the value to fit within the bounds of the range. */
-  public int squash(int value) {
-    return Math.min(Math.max(getMin(), value), getMax());
-  }
-
-  /** True both {@link #getMin()} and {@link #getMax()} are 0. */
-  public boolean isEmpty() {
-    return getMin() == 0 && getMax() == 0;
-  }
-
-  @Override
-  public int compareTo(PermissionRange o) {
-    return getName().compareTo(o.getName());
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder r = new StringBuilder();
-    if (getMin() < 0 && getMax() == 0) {
-      r.append(getMin());
-      r.append(' ');
-    } else {
-      if (getMin() != getMax()) {
-        if (0 <= getMin()) {
-          r.append('+');
-        }
-        r.append(getMin());
-        r.append("..");
-      }
-      if (0 <= getMax()) {
-        r.append('+');
-      }
-      r.append(getMax());
-      r.append(' ');
-    }
-    return r.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/PermissionRule.java b/java/com/google/gerrit/common/data/PermissionRule.java
deleted file mode 100644
index 8ab0a55..0000000
--- a/java/com/google/gerrit/common/data/PermissionRule.java
+++ /dev/null
@@ -1,296 +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.common.data;
-
-public class PermissionRule implements Comparable<PermissionRule> {
-  public static final String FORCE_PUSH = "Force Push";
-  public static final String FORCE_EDIT = "Force Edit";
-
-  public enum Action {
-    ALLOW,
-    DENY,
-    BLOCK,
-
-    INTERACTIVE,
-    BATCH
-  }
-
-  protected Action action = Action.ALLOW;
-  protected boolean force;
-  protected int min;
-  protected int max;
-  protected GroupReference group;
-
-  public PermissionRule() {}
-
-  public PermissionRule(GroupReference group) {
-    this.group = group;
-  }
-
-  public Action getAction() {
-    return action;
-  }
-
-  public void setAction(Action action) {
-    if (action == null) {
-      throw new NullPointerException("action");
-    }
-    this.action = action;
-  }
-
-  public boolean isDeny() {
-    return action == Action.DENY;
-  }
-
-  public void setDeny() {
-    action = Action.DENY;
-  }
-
-  public boolean isBlock() {
-    return action == Action.BLOCK;
-  }
-
-  public void setBlock() {
-    action = Action.BLOCK;
-  }
-
-  public boolean getForce() {
-    return force;
-  }
-
-  public void setForce(boolean newForce) {
-    force = newForce;
-  }
-
-  public int getMin() {
-    return min;
-  }
-
-  public void setMin(int min) {
-    this.min = min;
-  }
-
-  public void setMax(int max) {
-    this.max = max;
-  }
-
-  public int getMax() {
-    return max;
-  }
-
-  public void setRange(int newMin, int newMax) {
-    if (newMax < newMin) {
-      min = newMax;
-      max = newMin;
-    } else {
-      min = newMin;
-      max = newMax;
-    }
-  }
-
-  public GroupReference getGroup() {
-    return group;
-  }
-
-  public void setGroup(GroupReference newGroup) {
-    group = newGroup;
-  }
-
-  void mergeFrom(PermissionRule src) {
-    if (getAction() != src.getAction()) {
-      if (getAction() == Action.BLOCK || src.getAction() == Action.BLOCK) {
-        setAction(Action.BLOCK);
-
-      } else if (getAction() == Action.DENY || src.getAction() == Action.DENY) {
-        setAction(Action.DENY);
-
-      } else if (getAction() == Action.BATCH || src.getAction() == Action.BATCH) {
-        setAction(Action.BATCH);
-      }
-    }
-
-    setForce(getForce() || src.getForce());
-    setRange(Math.min(getMin(), src.getMin()), Math.max(getMax(), src.getMax()));
-  }
-
-  @Override
-  public int compareTo(PermissionRule o) {
-    int cmp = action(this) - action(o);
-    if (cmp == 0) {
-      cmp = range(o) - range(this);
-    }
-    if (cmp == 0) {
-      cmp = group(this).compareTo(group(o));
-    }
-    return cmp;
-  }
-
-  private static int action(PermissionRule a) {
-    switch (a.getAction()) {
-      case DENY:
-        return 0;
-      case ALLOW:
-      case BATCH:
-      case BLOCK:
-      case INTERACTIVE:
-      default:
-        return 1 + a.getAction().ordinal();
-    }
-  }
-
-  private static int range(PermissionRule a) {
-    return Math.abs(a.getMin()) + Math.abs(a.getMax());
-  }
-
-  private static String group(PermissionRule a) {
-    return a.getGroup().getName() != null ? a.getGroup().getName() : "";
-  }
-
-  @Override
-  public String toString() {
-    return asString(true);
-  }
-
-  public String asString(boolean canUseRange) {
-    StringBuilder r = new StringBuilder();
-
-    switch (getAction()) {
-      case ALLOW:
-        break;
-
-      case DENY:
-        r.append("deny ");
-        break;
-
-      case BLOCK:
-        r.append("block ");
-        break;
-
-      case INTERACTIVE:
-        r.append("interactive ");
-        break;
-
-      case BATCH:
-        r.append("batch ");
-        break;
-    }
-
-    if (getForce()) {
-      r.append("+force ");
-    }
-
-    if (canUseRange && (getMin() != 0 || getMax() != 0)) {
-      if (0 <= getMin()) {
-        r.append('+');
-      }
-      r.append(getMin());
-      r.append("..");
-      if (0 <= getMax()) {
-        r.append('+');
-      }
-      r.append(getMax());
-      r.append(' ');
-    }
-
-    r.append(getGroup().toConfigValue());
-
-    return r.toString();
-  }
-
-  public static PermissionRule fromString(String src, boolean mightUseRange) {
-    final String orig = src;
-    final PermissionRule rule = new PermissionRule();
-
-    src = src.trim();
-
-    if (src.startsWith("deny ")) {
-      rule.setAction(Action.DENY);
-      src = src.substring("deny ".length()).trim();
-
-    } else if (src.startsWith("block ")) {
-      rule.setAction(Action.BLOCK);
-      src = src.substring("block ".length()).trim();
-
-    } else if (src.startsWith("interactive ")) {
-      rule.setAction(Action.INTERACTIVE);
-      src = src.substring("interactive ".length()).trim();
-
-    } else if (src.startsWith("batch ")) {
-      rule.setAction(Action.BATCH);
-      src = src.substring("batch ".length()).trim();
-    }
-
-    if (src.startsWith("+force ")) {
-      rule.setForce(true);
-      src = src.substring("+force ".length()).trim();
-    }
-
-    if (mightUseRange && !GroupReference.isGroupReference(src)) {
-      int sp = src.indexOf(' ');
-      String range = src.substring(0, sp);
-
-      if (range.matches("^([+-]?\\d+)\\.\\.([+-]?\\d+)$")) {
-        int dotdot = range.indexOf("..");
-        int min = parseInt(range.substring(0, dotdot));
-        int max = parseInt(range.substring(dotdot + 2));
-        rule.setRange(min, max);
-      } else {
-        throw new IllegalArgumentException("Invalid range in rule: " + orig);
-      }
-
-      src = src.substring(sp + 1).trim();
-    }
-
-    String groupName = GroupReference.extractGroupName(src);
-    if (groupName != null) {
-      GroupReference group = new GroupReference();
-      group.setName(groupName);
-      rule.setGroup(group);
-    } else {
-      throw new IllegalArgumentException("Rule must include group: " + orig);
-    }
-
-    return rule;
-  }
-
-  public boolean hasRange() {
-    return getMin() != 0 || getMax() != 0;
-  }
-
-  public static int parseInt(String value) {
-    if (value.startsWith("+")) {
-      value = value.substring(1);
-    }
-    return Integer.parseInt(value);
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof PermissionRule)) {
-      return false;
-    }
-    final PermissionRule other = (PermissionRule) obj;
-    return action.equals(other.action)
-        && force == other.force
-        && min == other.min
-        && max == other.max
-        && group.equals(other.group);
-  }
-
-  @Override
-  public int hashCode() {
-    return group.hashCode();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitRecord.java b/java/com/google/gerrit/common/data/SubmitRecord.java
deleted file mode 100644
index fe5843ad..0000000
--- a/java/com/google/gerrit/common/data/SubmitRecord.java
+++ /dev/null
@@ -1,185 +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.common.data;
-
-import com.google.gerrit.entities.Account;
-import java.util.Collection;
-import java.util.List;
-import java.util.Objects;
-
-/** Describes the state and edits required to submit a change. */
-public class SubmitRecord {
-  public static boolean allRecordsOK(Collection<SubmitRecord> in) {
-    if (in == null || in.isEmpty()) {
-      // If the list is null or empty, it means that this Gerrit installation does not
-      // have any form of validation rules.
-      // Hence, the permission system should be used to determine if the change can be merged
-      // or not.
-      return true;
-    }
-
-    // The change can be submitted, unless at least one plugin prevents it.
-    return in.stream().map(SubmitRecord::status).allMatch(SubmitRecord.Status::allowsSubmission);
-  }
-
-  public enum Status {
-    // NOTE: These values are persisted in the index, so deleting or changing
-    // the name of any values requires a schema upgrade.
-
-    /** The change is ready for submission. */
-    OK,
-
-    /** Something is preventing this change from being submitted. */
-    NOT_READY,
-
-    /** The change has been closed. */
-    CLOSED,
-
-    /** The change was submitted bypassing submit rules. */
-    FORCED,
-
-    /**
-     * An internal server error occurred preventing computation.
-     *
-     * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
-     */
-    RULE_ERROR;
-
-    private boolean allowsSubmission() {
-      return this == OK || this == FORCED;
-    }
-  }
-
-  public Status status;
-  public List<Label> labels;
-  public List<SubmitRequirement> requirements;
-  public String errorMessage;
-
-  public static class Label {
-    public enum Status {
-      // NOTE: These values are persisted in the index, so deleting or changing
-      // the name of any values requires a schema upgrade.
-
-      /**
-       * This label provides what is necessary for submission.
-       *
-       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
-       * to the change.
-       */
-      OK,
-
-      /**
-       * This label prevents the change from being submitted.
-       *
-       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
-       * to the change.
-       */
-      REJECT,
-
-      /** The label is required for submission, but has not been satisfied. */
-      NEED,
-
-      /**
-       * The label may be set, but it's neither necessary for submission nor does it block
-       * submission if set.
-       */
-      MAY,
-
-      /**
-       * 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
-    }
-
-    public String label;
-    public Status status;
-    public Account.Id appliedBy;
-
-    @Override
-    public String toString() {
-      StringBuilder sb = new StringBuilder();
-      sb.append(label).append(": ").append(status);
-      if (appliedBy != null) {
-        sb.append(" by ").append(appliedBy);
-      }
-      return sb.toString();
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (o instanceof Label) {
-        Label l = (Label) o;
-        return Objects.equals(label, l.label)
-            && Objects.equals(status, l.status)
-            && Objects.equals(appliedBy, l.appliedBy);
-      }
-      return false;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(label, status, appliedBy);
-    }
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append(status);
-    if (status == Status.RULE_ERROR && errorMessage != null) {
-      sb.append('(').append(errorMessage).append(')');
-    }
-    sb.append('[');
-    if (labels != null) {
-      String delimiter = "";
-      for (Label label : labels) {
-        sb.append(delimiter).append(label);
-        delimiter = ", ";
-      }
-    }
-    sb.append("],[");
-    if (requirements != null) {
-      String delimiter = "";
-      for (SubmitRequirement requirement : requirements) {
-        sb.append(delimiter).append(requirement);
-        delimiter = ", ";
-      }
-    }
-    sb.append(']');
-    return sb.toString();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof SubmitRecord) {
-      SubmitRecord r = (SubmitRecord) o;
-      return Objects.equals(status, r.status)
-          && Objects.equals(labels, r.labels)
-          && Objects.equals(errorMessage, r.errorMessage)
-          && Objects.equals(requirements, r.requirements);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(status, labels, errorMessage, requirements);
-  }
-
-  private Status status() {
-    return status;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitRequirement.java b/java/com/google/gerrit/common/data/SubmitRequirement.java
deleted file mode 100644
index 2c341bf..0000000
--- a/java/com/google/gerrit/common/data/SubmitRequirement.java
+++ /dev/null
@@ -1,60 +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.common.data;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.CharMatcher;
-
-/** Describes a requirement to submit a change. */
-@AutoValue
-@AutoValue.CopyAnnotations
-public abstract class SubmitRequirement {
-  private static final CharMatcher TYPE_MATCHER =
-      CharMatcher.inRange('a', 'z')
-          .or(CharMatcher.inRange('A', 'Z'))
-          .or(CharMatcher.inRange('0', '9'))
-          .or(CharMatcher.anyOf("-_"));
-
-  @AutoValue.Builder
-  public abstract static class Builder {
-    public abstract Builder setType(String value);
-
-    public abstract Builder setFallbackText(String value);
-
-    public SubmitRequirement build() {
-      SubmitRequirement requirement = autoBuild();
-      checkState(
-          validateType(requirement.type()),
-          "SubmitRequirement's type contains non alphanumerical symbols.");
-      return requirement;
-    }
-
-    abstract SubmitRequirement autoBuild();
-  }
-
-  public abstract String fallbackText();
-
-  public abstract String type();
-
-  public static Builder builder() {
-    return new AutoValue_SubmitRequirement.Builder();
-  }
-
-  private static boolean validateType(String type) {
-    return TYPE_MATCHER.matchesAllOf(type);
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitTypeRecord.java b/java/com/google/gerrit/common/data/SubmitTypeRecord.java
deleted file mode 100644
index afb3bac..0000000
--- a/java/com/google/gerrit/common/data/SubmitTypeRecord.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// 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.common.data;
-
-import com.google.gerrit.extensions.client.SubmitType;
-
-/** Describes the submit type for a change. */
-public class SubmitTypeRecord {
-  public enum Status {
-    /** The type was computed successfully */
-    OK,
-
-    /**
-     * An internal server error occurred preventing computation.
-     *
-     * <p>Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
-     */
-    RULE_ERROR
-  }
-
-  public static SubmitTypeRecord OK(SubmitType type) {
-    return new SubmitTypeRecord(Status.OK, type, null);
-  }
-
-  public static SubmitTypeRecord error(String err) {
-    return new SubmitTypeRecord(SubmitTypeRecord.Status.RULE_ERROR, null, err);
-  }
-
-  /** Status enum value of the record. */
-  public final Status status;
-
-  /** Submit type of the record; never null if {@link #status} is {@code OK}. */
-  public final SubmitType type;
-
-  /** Submit type of the record; always null if {@link #status} is {@code OK}. */
-  public final String errorMessage;
-
-  private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
-    if (type == SubmitType.INHERIT) {
-      throw new IllegalArgumentException("Cannot output submit type " + type);
-    }
-    this.status = status;
-    this.type = type;
-    this.errorMessage = errorMessage;
-  }
-
-  public boolean isOk() {
-    return status == Status.OK;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append(status);
-    if (status == Status.RULE_ERROR && errorMessage != null) {
-      sb.append(" (").append(errorMessage).append(")");
-    }
-    if (type != null) {
-      sb.append('[');
-      sb.append(type.name());
-      sb.append(']');
-    }
-    return sb.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SubscribeSection.java b/java/com/google/gerrit/common/data/SubscribeSection.java
deleted file mode 100644
index 6ac4695..0000000
--- a/java/com/google/gerrit/common/data/SubscribeSection.java
+++ /dev/null
@@ -1,107 +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.common.data;
-
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Project;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import org.eclipse.jgit.transport.RefSpec;
-
-/** Portion of a {@link Project} describing superproject subscription rules. */
-public class SubscribeSection {
-
-  private final List<RefSpec> multiMatchRefSpecs;
-  private final List<RefSpec> matchingRefSpecs;
-  private final Project.NameKey project;
-
-  public SubscribeSection(Project.NameKey p) {
-    project = p;
-    matchingRefSpecs = new ArrayList<>();
-    multiMatchRefSpecs = new ArrayList<>();
-  }
-
-  public void addMatchingRefSpec(RefSpec spec) {
-    matchingRefSpecs.add(spec);
-  }
-
-  public void addMatchingRefSpec(String spec) {
-    RefSpec r = new RefSpec(spec);
-    matchingRefSpecs.add(r);
-  }
-
-  public void addMultiMatchRefSpec(String spec) {
-    RefSpec r = new RefSpec(spec, RefSpec.WildcardMode.ALLOW_MISMATCH);
-    multiMatchRefSpecs.add(r);
-  }
-
-  public Project.NameKey getProject() {
-    return project;
-  }
-
-  /**
-   * Determines if the <code>branch</code> could trigger a superproject update as allowed via this
-   * subscribe section.
-   *
-   * @param branch the branch to check
-   * @return if the branch could trigger a superproject update
-   */
-  public boolean appliesTo(BranchNameKey branch) {
-    for (RefSpec r : matchingRefSpecs) {
-      if (r.matchSource(branch.branch())) {
-        return true;
-      }
-    }
-    for (RefSpec r : multiMatchRefSpecs) {
-      if (r.matchSource(branch.branch())) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  public Collection<RefSpec> getMatchingRefSpecs() {
-    return Collections.unmodifiableCollection(matchingRefSpecs);
-  }
-
-  public Collection<RefSpec> getMultiMatchRefSpecs() {
-    return Collections.unmodifiableCollection(multiMatchRefSpecs);
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder ret = new StringBuilder();
-    ret.append("[SubscribeSection, project=");
-    ret.append(project);
-    if (!matchingRefSpecs.isEmpty()) {
-      ret.append(", matching=[");
-      for (RefSpec r : matchingRefSpecs) {
-        ret.append(r.toString());
-        ret.append(", ");
-      }
-    }
-    if (!multiMatchRefSpecs.isEmpty()) {
-      ret.append(", all=[");
-      for (RefSpec r : multiMatchRefSpecs) {
-        ret.append(r.toString());
-        ret.append(", ");
-      }
-    }
-    ret.append("]");
-    return ret.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
index d841aa6..beb62b4 100644
--- a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
+++ b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
@@ -20,8 +20,8 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 
 public class GroupReferenceSubject extends Subject {
 
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index c6400df..47fa383 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -18,9 +18,7 @@
 import java.util.regex.Pattern;
 
 public enum ElasticVersion {
-  V7_6("7.6.*"),
-  V7_7("7.7.*"),
-  V7_8("7.8.*");
+  V7_16("7.16.*");
 
   private final String version;
   private final Pattern pattern;
diff --git a/java/com/google/gerrit/entities/AccessSection.java b/java/com/google/gerrit/entities/AccessSection.java
new file mode 100644
index 0000000..d97bca8
--- /dev/null
+++ b/java/com/google/gerrit/entities/AccessSection.java
@@ -0,0 +1,165 @@
+// 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.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+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 java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+/** Portion of a {@link Project} describing access rules. */
+@AutoValue
+public abstract class AccessSection implements Comparable<AccessSection> {
+  /** Special name given to the global capabilities; not a valid reference. */
+  public static final String GLOBAL_CAPABILITIES = "GLOBAL_CAPABILITIES";
+  /** Pattern that matches all references in a project. */
+  public static final String ALL = "refs/*";
+
+  /** Pattern that matches all branches in a project. */
+  public static final String HEADS = "refs/heads/*";
+
+  /** Prefix that triggers a regular expression pattern. */
+  public static final String REGEX_PREFIX = "^";
+
+  /** Name of the access section. It could be a ref pattern or something else. */
+  public abstract String getName();
+
+  public abstract ImmutableList<Permission> getPermissions();
+
+  public static AccessSection create(String name) {
+    return builder(name).build();
+  }
+
+  public static Builder builder(String name) {
+    return new AutoValue_AccessSection.Builder().setName(name).setPermissions(ImmutableList.of());
+  }
+
+  /** @return true if the name is likely to be a valid reference section name. */
+  public static boolean isValidRefSectionName(String name) {
+    return name.startsWith("refs/") || name.startsWith("^refs/");
+  }
+
+  @Nullable
+  public Permission getPermission(String name) {
+    requireNonNull(name);
+    for (Permission p : getPermissions()) {
+      if (p.getName().equalsIgnoreCase(name)) {
+        return p;
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public final int compareTo(AccessSection o) {
+    return comparePattern().compareTo(o.comparePattern());
+  }
+
+  private String comparePattern() {
+    if (getName().startsWith(REGEX_PREFIX)) {
+      return getName().substring(REGEX_PREFIX.length());
+    }
+    return getName();
+  }
+
+  @Override
+  public final String toString() {
+    return "AccessSection[" + getName() + "]";
+  }
+
+  public Builder toBuilder() {
+    Builder b = autoToBuilder();
+    b.getPermissions().stream().map(Permission::toBuilder).forEach(p -> b.addPermission(p));
+    return b;
+  }
+
+  protected abstract Builder autoToBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    private final List<Permission.Builder> permissionBuilders;
+
+    protected Builder() {
+      permissionBuilders = new ArrayList<>();
+    }
+
+    public abstract Builder setName(String name);
+
+    public abstract String getName();
+
+    public Builder modifyPermissions(Consumer<List<Permission.Builder>> modification) {
+      modification.accept(permissionBuilders);
+      return this;
+    }
+
+    public Builder addPermission(Permission.Builder permission) {
+      requireNonNull(permission, "permission must be non-null");
+      return modifyPermissions(p -> p.add(permission));
+    }
+
+    public Builder remove(Permission.Builder permission) {
+      requireNonNull(permission, "permission must be non-null");
+      return removePermission(permission.getName());
+    }
+
+    public Builder removePermission(String name) {
+      requireNonNull(name, "name must be non-null");
+      return modifyPermissions(
+          p -> p.removeIf(permissionBuilder -> name.equalsIgnoreCase(permissionBuilder.getName())));
+    }
+
+    public Permission.Builder upsertPermission(String permissionName) {
+      requireNonNull(permissionName, "permissionName must be non-null");
+
+      Optional<Permission.Builder> maybePermission =
+          permissionBuilders.stream()
+              .filter(p -> p.getName().equalsIgnoreCase(permissionName))
+              .findAny();
+      if (maybePermission.isPresent()) {
+        return maybePermission.get();
+      }
+
+      Permission.Builder permission = Permission.builder(permissionName);
+      modifyPermissions(p -> p.add(permission));
+      return permission;
+    }
+
+    public AccessSection build() {
+      setPermissions(
+          permissionBuilders.stream().map(Permission.Builder::build).collect(toImmutableList()));
+      if (getPermissions().size()
+          > getPermissions().stream()
+              .map(Permission::getName)
+              .map(String::toLowerCase)
+              .distinct()
+              .count()) {
+        throw new IllegalArgumentException("duplicate permissions: " + getPermissions());
+      }
+      return autoBuild();
+    }
+
+    protected abstract AccessSection autoBuild();
+
+    protected abstract ImmutableList<Permission> getPermissions();
+
+    abstract Builder setPermissions(ImmutableList<Permission> permissions);
+  }
+}
diff --git a/java/com/google/gerrit/entities/AccountsSection.java b/java/com/google/gerrit/entities/AccountsSection.java
new file mode 100644
index 0000000..93083a2
--- /dev/null
+++ b/java/com/google/gerrit/entities/AccountsSection.java
@@ -0,0 +1,28 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+
+@AutoValue
+public abstract class AccountsSection {
+  public abstract ImmutableList<PermissionRule> getSameGroupVisibility();
+
+  public static AccountsSection create(List<PermissionRule> sameGroupVisibility) {
+    return new AutoValue_AccountsSection(ImmutableList.copyOf(sameGroupVisibility));
+  }
+}
diff --git a/java/com/google/gerrit/entities/Address.java b/java/com/google/gerrit/entities/Address.java
new file mode 100644
index 0000000..2324330
--- /dev/null
+++ b/java/com/google/gerrit/entities/Address.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+/** 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('>');
+    final int at = in.indexOf("@");
+    if (0 <= lt && lt < gt && lt + 1 < at && at + 1 < gt) {
+      final String email = in.substring(lt + 1, gt).trim();
+      final String name = in.substring(0, lt).trim();
+      int nameStart = 0;
+      int nameEnd = name.length();
+      if (name.startsWith("\"")) {
+        nameStart++;
+      }
+      if (name.endsWith("\"")) {
+        nameEnd--;
+      }
+      return Address.create(name.length() > 0 ? name.substring(nameStart, nameEnd) : null, email);
+    }
+
+    if (lt < 0 && gt < 0 && 0 < at && at < in.length() - 1) {
+      return Address.create(in);
+    }
+
+    throw new IllegalArgumentException("Invalid email address: " + in);
+  }
+
+  public static Address tryParse(String in) {
+    try {
+      return parse(in);
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
+  public static Address create(String email) {
+    return create(null, email);
+  }
+
+  public static Address create(String name, String email) {
+    return new AutoValue_Address(name, email);
+  }
+
+  @Nullable
+  public abstract String name();
+
+  public abstract String email();
+
+  @Override
+  public final int hashCode() {
+    return email().hashCode();
+  }
+
+  @Override
+  public final boolean equals(Object other) {
+    if (other instanceof Address) {
+      return email().equals(((Address) other).email());
+    }
+    return false;
+  }
+
+  @Override
+  public final String toString() {
+    return toHeaderString();
+  }
+
+  public String toHeaderString() {
+    if (name() != null) {
+      return quotedPhrase(name()) + " <" + email() + ">";
+    } else if (isSimple()) {
+      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);
+      if (c <= ' ' || 0x7F <= c || MUST_QUOTE_EMAIL.indexOf(c) != -1) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static String quotedPhrase(String name) {
+    if (EmailHeader.needsQuotedPrintable(name)) {
+      return EmailHeader.quotedPrintable(name);
+    }
+    for (int i = 0; i < name.length(); i++) {
+      final char c = name.charAt(i);
+      if (MUST_QUOTE_NAME.indexOf(c) != -1) {
+        return wrapInQuotes(name);
+      }
+    }
+    return name;
+  }
+
+  private static String wrapInQuotes(String name) {
+    final StringBuilder r = new StringBuilder(2 + name.length());
+    r.append('"');
+    for (int i = 0; i < name.length(); i++) {
+      char c = name.charAt(i);
+      if (c == '"' || c == '\\') {
+        r.append('\\');
+      }
+      r.append(c);
+    }
+    r.append('"');
+    return r.toString();
+  }
+}
diff --git a/java/com/google/gerrit/entities/AttentionSetUpdate.java b/java/com/google/gerrit/entities/AttentionSetUpdate.java
index 45588722..2e58608 100644
--- a/java/com/google/gerrit/entities/AttentionSetUpdate.java
+++ b/java/com/google/gerrit/entities/AttentionSetUpdate.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import java.time.Instant;
 
 /**
@@ -23,9 +24,7 @@
  * 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.
+ * <p>See {@link AttentionSetInput} for the representation in the API.
  */
 @AutoValue
 public abstract class AttentionSetUpdate {
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 26265ae..66d1869 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",
+        "//lib/flogger:api",
         "//proto:cache_java_proto",
         "//proto:entities_java_proto",
     ],
diff --git a/java/com/google/gerrit/entities/BranchOrderSection.java b/java/com/google/gerrit/entities/BranchOrderSection.java
new file mode 100644
index 0000000..f964e59
--- /dev/null
+++ b/java/com/google/gerrit/entities/BranchOrderSection.java
@@ -0,0 +1,62 @@
+// 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.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.Collection;
+
+/**
+ * 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.
+ */
+@AutoValue
+public abstract class BranchOrderSection {
+
+  /**
+   * Branch names ordered from least to the most stable.
+   *
+   * <p>Typically the order will be like: master, stable-M.N, stable-M.N-1, ...
+   *
+   * <p>Ref names in this list are exactly as they appear in {@code project.config}
+   */
+  public abstract ImmutableList<String> order();
+
+  public static BranchOrderSection create(Collection<String> order) {
+    // Do not mutate the given list as this will be written back to disk when ProjectConfig is
+    // stored.
+    return new AutoValue_BranchOrderSection(ImmutableList.copyOf(order));
+  }
+
+  /**
+   * Returns the tail list of branches that are more stable - so lower in the entire list ordered by
+   * priority compared to the provided branch. Always returns a fully qualified ref name (including
+   * the refs/heads/ prefix).
+   */
+  public ImmutableList<String> getMoreStable(String branch) {
+    ImmutableList<String> fullyQualifiedOrder =
+        order().stream().map(RefNames::fullName).collect(toImmutableList());
+    int i = fullyQualifiedOrder.indexOf(RefNames.fullName(branch));
+    if (0 <= i) {
+      return fullyQualifiedOrder.subList(i + 1, fullyQualifiedOrder.size());
+    }
+    return ImmutableList.of();
+  }
+}
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
new file mode 100644
index 0000000..0b755b7
--- /dev/null
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -0,0 +1,273 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static com.google.common.base.Preconditions.checkState;
+
+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 java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Cached representation of values parsed from {@link
+ * com.google.gerrit.server.project.ProjectConfig}.
+ *
+ * <p>This class is immutable and thread-safe.
+ */
+@AutoValue
+public abstract class CachedProjectConfig {
+  public abstract Project getProject();
+
+  public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
+
+  /** Returns a set of all groups used by this configuration. */
+  public ImmutableSet<AccountGroup.UUID> getAllGroupUUIDs() {
+    return getGroups().keySet();
+  }
+
+  /**
+   * Returns the group reference for a {@link AccountGroup.UUID}, if the group is used by at least
+   * one rule.
+   */
+  public Optional<GroupReference> getGroup(AccountGroup.UUID uuid) {
+    return Optional.ofNullable(getGroups().get(uuid));
+  }
+
+  /**
+   * Returns the group reference for matching the given {@code name}, if the group is used by at
+   * least one rule.
+   */
+  public Optional<GroupReference> getGroupByName(@Nullable String name) {
+    if (name == null) {
+      return Optional.empty();
+    }
+    return getGroups().values().stream().filter(g -> name.equals(g.getName())).findAny();
+  }
+
+  /** Returns the account section containing visibility information about accounts. */
+  public abstract AccountsSection getAccountsSection();
+
+  /** Returns a map of {@link AccessSection}s keyed by their name. */
+  public abstract ImmutableMap<String, AccessSection> getAccessSections();
+
+  /** Returns the {@link AccessSection} with to the given name. */
+  public Optional<AccessSection> getAccessSection(String refName) {
+    return Optional.ofNullable(getAccessSections().get(refName));
+  }
+
+  /** Returns all {@link AccessSection} names. */
+  public ImmutableSet<String> getAccessSectionNames() {
+    return ImmutableSet.copyOf(getAccessSections().keySet());
+  }
+
+  /**
+   * Returns the {@link BranchOrderSection} containing the order in which branches should be shown.
+   */
+  public abstract Optional<BranchOrderSection> getBranchOrderSection();
+
+  /** Returns the {@link ContributorAgreement}s keyed by their name. */
+  public abstract ImmutableMap<String, ContributorAgreement> getContributorAgreements();
+
+  /** Returns the {@link NotifyConfig}s keyed by their name. */
+  public abstract ImmutableMap<String, NotifyConfig> getNotifySections();
+
+  /** Returns the {@link LabelType}s keyed by their name. */
+  public abstract ImmutableMap<String, LabelType> getLabelSections();
+
+  /** Returns configured {@link ConfiguredMimeTypes}s. */
+  public abstract ConfiguredMimeTypes getMimeTypes();
+
+  /** Returns {@link SubscribeSection} keyed by the {@link Project.NameKey} they reference. */
+  public abstract ImmutableMap<Project.NameKey, SubscribeSection> getSubscribeSections();
+
+  /** Returns {@link StoredCommentLinkInfo} keyed by their name. */
+  public abstract ImmutableMap<String, StoredCommentLinkInfo> getCommentLinkSections();
+
+  /** Returns the blob ID of the {@code rules.pl} file, if present. */
+  public abstract Optional<ObjectId> getRulesId();
+
+  // TODO(hiesel): This should not have to be an Optional.
+  /** Returns the SHA1 of the {@code refs/meta/config} branch. */
+  public abstract Optional<ObjectId> getRevision();
+
+  /** Returns the maximum allowed object size. */
+  public abstract long getMaxObjectSizeLimit();
+
+  /** Returns {@code true} if received objects should be checked for validity. */
+  public abstract boolean getCheckReceivedObjects();
+
+  /** Returns a list of panel sections keyed by title. */
+  public abstract ImmutableMap<String, ImmutableList<String>> getExtensionPanelSections();
+
+  public ImmutableList<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
+    return filterSubscribeSectionsByBranch(getSubscribeSections().values(), branch);
+  }
+
+  public abstract ImmutableMap<String, String> getPluginConfigs();
+
+  /**
+   * Returns the {@link Config} that got parsed from the specified {@code fileName} on {@code
+   * refs/meta/config}. The returned instance is a defensive copy of the cached value.
+   *
+   * @param fileName the name of the file. Must end in {@code .config}.
+   * @return an {@link Optional} of the {@link Config}. {@link Optional#empty()} if the file was not
+   *     found or could not be parsed. {@link com.google.gerrit.server.project.ProjectConfig} will
+   *     surface validation errors in case of a parsing issue.
+   */
+  public Optional<Config> getProjectLevelConfig(String fileName) {
+    checkState(fileName.endsWith(".config"), "file name must end in .config");
+    if (getProjectLevelConfigs().containsKey(fileName)) {
+      Config config = new Config();
+      try {
+        config.fromText(getProjectLevelConfigs().get(fileName));
+      } catch (ConfigInvalidException e) {
+        // This is OK to propagate as IllegalStateException because it's a programmer error.
+        // The config was converted to a String using Config#toText. So #fromText must not
+        // throw a ConfigInvalidException
+        throw new IllegalStateException("invalid config for " + fileName, e);
+      }
+      return Optional.of(config);
+    }
+    return Optional.empty();
+  }
+
+  public abstract ImmutableMap<String, String> getProjectLevelConfigs();
+
+  public static Builder builder() {
+    return new AutoValue_CachedProjectConfig.Builder();
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setProject(Project value);
+
+    public abstract Builder setAccountsSection(AccountsSection value);
+
+    public abstract Builder setBranchOrderSection(Optional<BranchOrderSection> value);
+
+    public Builder addGroup(GroupReference groupReference) {
+      groupsBuilder().put(groupReference.getUUID(), groupReference);
+      return this;
+    }
+
+    public Builder addAccessSection(AccessSection accessSection) {
+      accessSectionsBuilder().put(accessSection.getName(), accessSection);
+      return this;
+    }
+
+    public Builder addContributorAgreement(ContributorAgreement contributorAgreement) {
+      contributorAgreementsBuilder().put(contributorAgreement.getName(), contributorAgreement);
+      return this;
+    }
+
+    public Builder addNotifySection(NotifyConfig notifyConfig) {
+      notifySectionsBuilder().put(notifyConfig.getName(), notifyConfig);
+      return this;
+    }
+
+    public Builder addLabelSection(LabelType labelType) {
+      labelSectionsBuilder().put(labelType.getName(), labelType);
+      return this;
+    }
+
+    public abstract Builder setMimeTypes(ConfiguredMimeTypes value);
+
+    public Builder addSubscribeSection(SubscribeSection subscribeSection) {
+      subscribeSectionsBuilder().put(subscribeSection.project(), subscribeSection);
+      return this;
+    }
+
+    public Builder addCommentLinkSection(StoredCommentLinkInfo storedCommentLinkInfo) {
+      commentLinkSectionsBuilder().put(storedCommentLinkInfo.getName(), storedCommentLinkInfo);
+      return this;
+    }
+
+    public abstract Builder setRulesId(Optional<ObjectId> value);
+
+    public abstract Builder setRevision(Optional<ObjectId> value);
+
+    public abstract Builder setMaxObjectSizeLimit(long value);
+
+    public abstract Builder setCheckReceivedObjects(boolean value);
+
+    public abstract ImmutableMap.Builder<String, ImmutableList<String>>
+        extensionPanelSectionsBuilder();
+
+    public Builder setExtensionPanelSections(Map<String, List<String>> value) {
+      value
+          .entrySet()
+          .forEach(
+              e ->
+                  extensionPanelSectionsBuilder()
+                      .put(e.getKey(), ImmutableList.copyOf(e.getValue())));
+      return this;
+    }
+
+    abstract ImmutableMap.Builder<String, String> pluginConfigsBuilder();
+
+    public Builder addPluginConfig(String pluginName, String pluginConfig) {
+      pluginConfigsBuilder().put(pluginName, pluginConfig);
+      return this;
+    }
+
+    abstract ImmutableMap.Builder<String, String> projectLevelConfigsBuilder();
+
+    public Builder addProjectLevelConfig(String configFileName, String config) {
+      projectLevelConfigsBuilder().put(configFileName, config);
+      return this;
+    }
+
+    public abstract CachedProjectConfig build();
+
+    protected abstract ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, AccessSection> accessSectionsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, ContributorAgreement>
+        contributorAgreementsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, NotifyConfig> notifySectionsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, LabelType> labelSectionsBuilder();
+
+    protected abstract ImmutableMap.Builder<Project.NameKey, SubscribeSection>
+        subscribeSectionsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, StoredCommentLinkInfo>
+        commentLinkSectionsBuilder();
+  }
+
+  private static ImmutableList<SubscribeSection> filterSubscribeSectionsByBranch(
+      Collection<SubscribeSection> allSubscribeSections, BranchNameKey branch) {
+    ImmutableList.Builder<SubscribeSection> ret = ImmutableList.builder();
+    for (SubscribeSection s : allSubscribeSections) {
+      if (s.appliesTo(branch)) {
+        ret.add(s);
+      }
+    }
+    return ret.build();
+  }
+}
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index b36b5f9..845a9bb 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -39,7 +39,7 @@
  *          |
  *          +- {@link PatchSetApproval}: a +/- vote on the change's current state.
  *          |
- *          +- {@link Comment}: comment about a specific line
+ *          +- {@link HumanComment}: comment about a specific line
  * </pre>
  *
  * <p>
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 9c58fef..37b8620 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -24,15 +24,15 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
- * This class represents inline comments in NoteDb. This means it determines the JSON format for
- * inline comments in the revision notes that NoteDb uses to persist inline comments.
+ * This class is a base class that can be extended by the different types of inline comment
+ * entities.
  *
  * <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.
+ * <p>Consider updating {@link #getCommentFieldApproximateSize()} when adding/changing fields.
  */
-public class Comment {
+public abstract class Comment {
   public enum Status {
     DRAFT('d'),
 
@@ -231,7 +231,6 @@
   private String revId;
 
   public String serverId;
-  public boolean unresolved;
 
   public Comment(Comment c) {
     this(
@@ -240,14 +239,13 @@
         new Timestamp(c.writtenOn.getTime()),
         c.side,
         c.message,
-        c.serverId,
-        c.unresolved);
+        c.serverId);
     this.lineNbr = c.lineNbr;
     this.realAuthor = c.realAuthor;
+    this.parentUuid = c.parentUuid;
     this.range = c.range != null ? new Range(c.range) : null;
     this.tag = c.tag;
     this.revId = c.revId;
-    this.unresolved = c.unresolved;
   }
 
   public Comment(
@@ -256,8 +254,7 @@
       Timestamp writtenOn,
       short side,
       String message,
-      String serverId,
-      boolean unresolved) {
+      String serverId) {
     this.key = key;
     this.author = new Comment.Identity(author);
     this.realAuthor = this.author;
@@ -265,7 +262,6 @@
     this.side = side;
     this.message = message;
     this.serverId = serverId;
-    this.unresolved = unresolved;
   }
 
   public void setLineNbrAndRange(
@@ -301,11 +297,13 @@
    * 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() {
+  protected int getCommentFieldApproximateSize() {
     return nullableLength(message, parentUuid, tag, revId, serverId)
         + (key != null ? nullableLength(key.filename, key.uuid) : 0);
   }
 
+  public abstract int getApproximateSize();
+
   static int nullableLength(String... strings) {
     int length = 0;
     for (String s : strings) {
@@ -331,8 +329,7 @@
         && Objects.equals(range, c.range)
         && Objects.equals(tag, c.tag)
         && Objects.equals(revId, c.revId)
-        && Objects.equals(serverId, c.serverId)
-        && unresolved == c.unresolved;
+        && Objects.equals(serverId, c.serverId);
   }
 
   @Override
@@ -349,8 +346,7 @@
         range,
         tag,
         revId,
-        serverId,
-        unresolved);
+        serverId);
   }
 
   @Override
@@ -370,7 +366,6 @@
         .add("parentUuid", Objects.toString(parentUuid, ""))
         .add("range", Objects.toString(range, ""))
         .add("revId", Objects.toString(revId, ""))
-        .add("tag", Objects.toString(tag, ""))
-        .add("unresolved", unresolved);
+        .add("tag", Objects.toString(tag, ""));
   }
 }
diff --git a/java/com/google/gerrit/entities/ConfiguredMimeTypes.java b/java/com/google/gerrit/entities/ConfiguredMimeTypes.java
new file mode 100644
index 0000000..6ba89c9
--- /dev/null
+++ b/java/com/google/gerrit/entities/ConfiguredMimeTypes.java
@@ -0,0 +1,154 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.errors.InvalidPatternException;
+import org.eclipse.jgit.fnmatch.FileNameMatcher;
+import org.eclipse.jgit.lib.Config;
+
+@AutoValue
+public abstract class ConfiguredMimeTypes {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String MIMETYPE = "mimetype";
+  private static final String KEY_PATH = "path";
+
+  public abstract ImmutableList<TypeMatcher> matchers();
+
+  public static ConfiguredMimeTypes create(String projectName, Config rc) {
+    Set<String> types = rc.getSubsections(MIMETYPE);
+    ImmutableList.Builder<TypeMatcher> matchers = ImmutableList.builder();
+    if (!types.isEmpty()) {
+      for (String typeName : types) {
+        for (String path : rc.getStringList(MIMETYPE, typeName, KEY_PATH)) {
+          try {
+            if (path.startsWith("^")) {
+              matchers.add(new ReType(typeName, path));
+            } else {
+              matchers.add(new FnType(typeName, path));
+            }
+          } catch (PatternSyntaxException | InvalidPatternException e) {
+            logger.atWarning().log(
+                "Ignoring invalid %s.%s.%s = %s in project %s: %s",
+                MIMETYPE, typeName, KEY_PATH, path, projectName, e.getMessage());
+          }
+        }
+      }
+    }
+    return new AutoValue_ConfiguredMimeTypes(matchers.build());
+  }
+
+  public static ConfiguredMimeTypes create(ImmutableList<TypeMatcher> matchers) {
+    return new AutoValue_ConfiguredMimeTypes(matchers);
+  }
+
+  @Nullable
+  public String getMimeType(String path) {
+    for (TypeMatcher m : matchers()) {
+      if (m.matches(path)) {
+        return m.type;
+      }
+    }
+    return null;
+  }
+
+  public abstract static class TypeMatcher {
+    private final String type;
+    private final String pattern;
+
+    private TypeMatcher(String type, String pattern) {
+      this.type = type;
+      this.pattern = pattern;
+    }
+
+    public String getPattern() {
+      return pattern;
+    }
+
+    public String getType() {
+      return type;
+    }
+
+    protected abstract boolean matches(String path);
+  }
+
+  public static class FnType extends TypeMatcher {
+    private final FileNameMatcher matcher;
+
+    public FnType(String type, String pattern) throws InvalidPatternException {
+      super(type, pattern);
+      this.matcher = new FileNameMatcher(pattern, null);
+    }
+
+    @Override
+    protected boolean matches(String input) {
+      FileNameMatcher m = new FileNameMatcher(matcher);
+      m.append(input);
+      return m.isMatch();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof FnType)) {
+        return false;
+      }
+      FnType other = (FnType) o;
+      return Objects.equals(other.getType(), getType())
+          && Objects.equals(other.getPattern(), getPattern());
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(getType(), getPattern());
+    }
+  }
+
+  public static class ReType extends TypeMatcher {
+    private final Pattern re;
+
+    public ReType(String type, String pattern) throws PatternSyntaxException {
+      super(type, pattern);
+      this.re = Pattern.compile(pattern);
+    }
+
+    @Override
+    protected boolean matches(String input) {
+      return re.matcher(input).matches();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof ReType)) {
+        return false;
+      }
+      ReType other = (ReType) o;
+      return Objects.equals(other.getType(), getType())
+          && Objects.equals(other.getPattern(), getPattern());
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(getType(), getPattern());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/ContributorAgreement.java b/java/com/google/gerrit/entities/ContributorAgreement.java
new file mode 100644
index 0000000..1d933b5
--- /dev/null
+++ b/java/com/google/gerrit/entities/ContributorAgreement.java
@@ -0,0 +1,80 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.util.List;
+
+/** Portion of a {@link Project} describing a single contributor agreement. */
+@AutoValue
+public abstract class ContributorAgreement implements Comparable<ContributorAgreement> {
+  public abstract String getName();
+
+  @Nullable
+  public abstract String getDescription();
+
+  public abstract ImmutableList<PermissionRule> getAccepted();
+
+  @Nullable
+  public abstract GroupReference getAutoVerify();
+
+  @Nullable
+  public abstract String getAgreementUrl();
+
+  public abstract ImmutableList<String> getExcludeProjectsRegexes();
+
+  public abstract ImmutableList<String> getMatchProjectsRegexes();
+
+  public static ContributorAgreement.Builder builder(String name) {
+    return new AutoValue_ContributorAgreement.Builder()
+        .setName(name)
+        .setAccepted(ImmutableList.of())
+        .setExcludeProjectsRegexes(ImmutableList.of())
+        .setMatchProjectsRegexes(ImmutableList.of());
+  }
+
+  @Override
+  public final int compareTo(ContributorAgreement o) {
+    return getName().compareTo(o.getName());
+  }
+
+  @Override
+  public final String toString() {
+    return "ContributorAgreement[" + getName() + "]";
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(String name);
+
+    public abstract Builder setDescription(@Nullable String description);
+
+    public abstract Builder setAccepted(ImmutableList<PermissionRule> accepted);
+
+    public abstract Builder setAutoVerify(@Nullable GroupReference autoVerify);
+
+    public abstract Builder setAgreementUrl(@Nullable String agreementUrl);
+
+    public abstract Builder setExcludeProjectsRegexes(List<String> excludeProjectsRegexes);
+
+    public abstract Builder setMatchProjectsRegexes(List<String> matchProjectsRegexes);
+
+    public abstract ContributorAgreement build();
+  }
+}
diff --git a/java/com/google/gerrit/entities/EmailHeader.java b/java/com/google/gerrit/entities/EmailHeader.java
new file mode 100644
index 0000000..71414c7
--- /dev/null
+++ b/java/com/google/gerrit/entities/EmailHeader.java
@@ -0,0 +1,233 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.MoreObjects;
+import java.io.IOException;
+import java.io.Writer;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+public abstract class EmailHeader {
+  public abstract boolean isEmpty();
+
+  public abstract void write(Writer w) throws IOException;
+
+  public static class String extends EmailHeader {
+    private final java.lang.String value;
+
+    public String(java.lang.String v) {
+      value = v;
+    }
+
+    public java.lang.String getString() {
+      return value;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return value == null || value.length() == 0;
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      if (needsQuotedPrintable(value)) {
+        w.write(quotedPrintable(value));
+      } else {
+        w.write(value);
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof String) && Objects.equals(value, ((String) o).value);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(value).toString();
+    }
+  }
+
+  public static boolean needsQuotedPrintable(java.lang.String value) {
+    for (int i = 0; i < value.length(); i++) {
+      if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  static boolean needsQuotedPrintableWithinPhrase(int cp) {
+    switch (cp) {
+      case '!':
+      case '*':
+      case '+':
+      case '-':
+      case '/':
+      case '=':
+      case '_':
+        return false;
+      default:
+        if (('a' <= cp && cp <= 'z') || ('A' <= cp && cp <= 'Z') || ('0' <= cp && cp <= '9')) {
+          return false;
+        }
+        return true;
+    }
+  }
+
+  public static java.lang.String quotedPrintable(java.lang.String value) {
+    final StringBuilder r = new StringBuilder();
+
+    r.append("=?UTF-8?Q?");
+    for (int i = 0; i < value.length(); i++) {
+      final int cp = value.codePointAt(i);
+      if (cp == ' ') {
+        r.append('_');
+
+      } else if (needsQuotedPrintableWithinPhrase(cp)) {
+        byte[] buf = new java.lang.String(Character.toChars(cp)).getBytes(UTF_8);
+        for (byte b : buf) {
+          r.append('=');
+          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
+          r.append(Integer.toHexString(b & 0x0f).toUpperCase());
+        }
+
+      } else {
+        r.append(Character.toChars(cp));
+      }
+    }
+    r.append("?=");
+
+    return r.toString();
+  }
+
+  public static class Date extends EmailHeader {
+    private final java.util.Date value;
+
+    public Date(java.util.Date v) {
+      value = v;
+    }
+
+    public java.util.Date getDate() {
+      return value;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return value == null;
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      final SimpleDateFormat fmt;
+      // Mon, 1 Jun 2009 10:49:44 -0700
+      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
+      w.write(fmt.format(value));
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof Date) && Objects.equals(value, ((Date) o).value);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(value).toString();
+    }
+  }
+
+  public static class AddressList extends EmailHeader {
+    private final List<Address> list = new ArrayList<>();
+
+    public AddressList() {}
+
+    public AddressList(Address addr) {
+      add(addr);
+    }
+
+    public List<Address> getAddressList() {
+      return Collections.unmodifiableList(list);
+    }
+
+    public void add(Address addr) {
+      list.add(addr);
+    }
+
+    public void remove(java.lang.String email) {
+      list.removeIf(address -> address.email().equals(email));
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return list.isEmpty();
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      int len = 8;
+      boolean firstAddress = true;
+      boolean needComma = false;
+      for (Address addr : list) {
+        java.lang.String s = addr.toHeaderString();
+        if (firstAddress) {
+          firstAddress = false;
+        } else if (72 < len + s.length()) {
+          w.write(",\r\n\t");
+          len = 8;
+          needComma = false;
+        }
+
+        if (needComma) {
+          w.write(", ");
+        }
+        w.write(s);
+        len += s.length();
+        needComma = true;
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(list);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof AddressList) && Objects.equals(list, ((AddressList) o).list);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(list).toString();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/GroupDescription.java b/java/com/google/gerrit/entities/GroupDescription.java
new file mode 100644
index 0000000..e950257
--- /dev/null
+++ b/java/com/google/gerrit/entities/GroupDescription.java
@@ -0,0 +1,66 @@
+// 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.entities;
+
+import com.google.gerrit.common.Nullable;
+import java.sql.Timestamp;
+import java.util.Set;
+
+/** Group methods exposed by the GroupBackend. */
+public class GroupDescription {
+  /** The Basic information required to be exposed by any Group. */
+  public interface Basic {
+    /** @return the non-null UUID of the group. */
+    AccountGroup.UUID getGroupUUID();
+
+    /** @return the non-null name of the group. */
+    String getName();
+
+    /**
+     * @return optional email address to send to the group's members. If provided, Gerrit will use
+     *     this email address to send change notifications to the group.
+     */
+    @Nullable
+    String getEmailAddress();
+
+    /**
+     * @return optional URL to information about the group. Typically a URL to a web page that
+     *     permits users to apply to join the group, or manage their membership.
+     */
+    @Nullable
+    String getUrl();
+  }
+
+  /** The extended information exposed by internal groups. */
+  public interface Internal extends Basic {
+
+    AccountGroup.Id getId();
+
+    @Nullable
+    String getDescription();
+
+    AccountGroup.UUID getOwnerGroupUUID();
+
+    boolean isVisibleToAll();
+
+    Timestamp getCreatedOn();
+
+    Set<Account.Id> getMembers();
+
+    Set<AccountGroup.UUID> getSubgroups();
+  }
+
+  private GroupDescription() {}
+}
diff --git a/java/com/google/gerrit/entities/GroupReference.java b/java/com/google/gerrit/entities/GroupReference.java
new file mode 100644
index 0000000..208ba0f
--- /dev/null
+++ b/java/com/google/gerrit/entities/GroupReference.java
@@ -0,0 +1,99 @@
+// 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.entities;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+
+/** Describes a group within a projects {@link AccessSection}s. */
+@AutoValue
+public abstract class GroupReference implements Comparable<GroupReference> {
+
+  private static final String PREFIX = "group ";
+
+  public static GroupReference forGroup(GroupDescription.Basic group) {
+    return GroupReference.create(group.getGroupUUID(), group.getName());
+  }
+
+  public static boolean isGroupReference(String configValue) {
+    return configValue != null && configValue.startsWith(PREFIX);
+  }
+
+  @Nullable
+  public static String extractGroupName(String configValue) {
+    if (!isGroupReference(configValue)) {
+      return null;
+    }
+    return configValue.substring(PREFIX.length()).trim();
+  }
+
+  @Nullable
+  public abstract AccountGroup.UUID getUUID();
+
+  public abstract String getName();
+
+  /**
+   * Create a group reference.
+   *
+   * @param uuid UUID of the group, must not be {@code null}
+   * @param name the group name, must not be {@code null}
+   */
+  public static GroupReference create(AccountGroup.UUID uuid, String name) {
+    return new AutoValue_GroupReference(requireNonNull(uuid), requireNonNull(name));
+  }
+
+  /**
+   * Create a group reference where the group's name couldn't be resolved.
+   *
+   * @param name the group name, must not be {@code null}
+   */
+  public static GroupReference create(String name) {
+    return new AutoValue_GroupReference(null, name);
+  }
+
+  @Override
+  public final int compareTo(GroupReference o) {
+    return uuid(this).compareTo(uuid(o));
+  }
+
+  private static String uuid(GroupReference a) {
+    if (a.getUUID() != null && a.getUUID().get() != null) {
+      return a.getUUID().get();
+    }
+
+    return "?";
+  }
+
+  @Override
+  public final int hashCode() {
+    return uuid(this).hashCode();
+  }
+
+  @Override
+  public final boolean equals(Object o) {
+    return o instanceof GroupReference && compareTo((GroupReference) o) == 0;
+  }
+
+  @Override
+  public final String toString() {
+    return "Group[" + getName() + " / " + getUUID() + "]";
+  }
+
+  public String toConfigValue() {
+    return PREFIX + getName();
+  }
+}
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
new file mode 100644
index 0000000..50bee8d
--- /dev/null
+++ b/java/com/google/gerrit/entities/HumanComment.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.entities;
+
+import java.sql.Timestamp;
+import java.util.Objects;
+
+/**
+ * This class represents inline human comments in NoteDb. This means it determines the JSON format
+ * for inline comments in the revision notes that NoteDb uses to persist inline comments.
+ *
+ * <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 HumanComment extends Comment {
+
+  public boolean unresolved;
+
+  public HumanComment(
+      Key key,
+      Account.Id author,
+      Timestamp writtenOn,
+      short side,
+      String message,
+      String serverId,
+      boolean unresolved) {
+    super(key, author, writtenOn, side, message, serverId);
+    this.unresolved = unresolved;
+  }
+
+  public HumanComment(HumanComment comment) {
+    super(comment);
+  }
+
+  @Override
+  public int getApproximateSize() {
+    return super.getCommentFieldApproximateSize();
+  }
+
+  @Override
+  public String toString() {
+    return toStringHelper().add("unresolved", unresolved).toString();
+  }
+
+  @Override
+  public boolean equals(Object otherObject) {
+    if (!(otherObject instanceof HumanComment)) {
+      return false;
+    }
+    if (!super.equals(otherObject)) {
+      return false;
+    }
+    HumanComment otherComment = (HumanComment) otherObject;
+    return unresolved == otherComment.unresolved;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), unresolved);
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelFunction.java b/java/com/google/gerrit/entities/LabelFunction.java
new file mode 100644
index 0000000..f361741
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelFunction.java
@@ -0,0 +1,123 @@
+// 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.entities;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.SubmitRecord.Label;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Functions for determining submittability based on label votes.
+ *
+ * <p>Only describes built-in label functions. Admins can extend the logic arbitrarily using Prolog
+ * rules, in which case the choice of function in the project config is ignored.
+ *
+ * <p>Function semantics are documented in {@code config-labels.txt}, and actual behavior is
+ * implemented both in Prolog in {@code gerrit_common.pl} and in the {@link #check} method.
+ */
+public enum LabelFunction {
+  ANY_WITH_BLOCK("AnyWithBlock", true, false, false),
+  MAX_WITH_BLOCK("MaxWithBlock", true, true, true),
+  MAX_NO_BLOCK("MaxNoBlock", false, true, true),
+  NO_BLOCK("NoBlock"),
+  NO_OP("NoOp"),
+  PATCH_SET_LOCK("PatchSetLock");
+
+  public static final Map<String, LabelFunction> ALL;
+
+  static {
+    Map<String, LabelFunction> all = new LinkedHashMap<>();
+    for (LabelFunction f : values()) {
+      all.put(f.getFunctionName(), f);
+    }
+    ALL = Collections.unmodifiableMap(all);
+  }
+
+  public static Optional<LabelFunction> parse(@Nullable String str) {
+    return Optional.ofNullable(ALL.get(str));
+  }
+
+  private final String name;
+  private final boolean isBlock;
+  private final boolean isRequired;
+  private final boolean requiresMaxValue;
+
+  LabelFunction(String name) {
+    this(name, false, false, false);
+  }
+
+  LabelFunction(String name, boolean isBlock, boolean isRequired, boolean requiresMaxValue) {
+    this.name = name;
+    this.isBlock = isBlock;
+    this.isRequired = isRequired;
+    this.requiresMaxValue = requiresMaxValue;
+  }
+
+  /** The function name as defined in documentation and {@code project.config}. */
+  public String getFunctionName() {
+    return name;
+  }
+
+  /** Whether the label is a "block" label, meaning a minimum vote will prevent submission. */
+  public boolean isBlock() {
+    return isBlock;
+  }
+
+  /** Whether the label is a mandatory label, meaning absence of votes will prevent submission. */
+  public boolean isRequired() {
+    return isRequired;
+  }
+
+  /** Whether the label requires a vote with the maximum value to allow submission. */
+  public boolean isMaxValueRequired() {
+    return requiresMaxValue;
+  }
+
+  public Label check(LabelType labelType, Iterable<PatchSetApproval> approvals) {
+    Label submitRecordLabel = new Label();
+    submitRecordLabel.label = labelType.getName();
+
+    submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
+    if (isRequired) {
+      submitRecordLabel.status = SubmitRecord.Label.Status.NEED;
+    }
+
+    for (PatchSetApproval a : approvals) {
+      if (a.value() == 0) {
+        continue;
+      }
+
+      if (isBlock && labelType.isMaxNegative(a)) {
+        submitRecordLabel.appliedBy = a.accountId();
+        submitRecordLabel.status = SubmitRecord.Label.Status.REJECT;
+        return submitRecordLabel;
+      }
+
+      if (labelType.isMaxPositive(a) || !requiresMaxValue) {
+        submitRecordLabel.appliedBy = a.accountId();
+
+        submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
+        if (isRequired) {
+          submitRecordLabel.status = SubmitRecord.Label.Status.OK;
+        }
+      }
+    }
+
+    return submitRecordLabel;
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
new file mode 100644
index 0000000..a8d4da5
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -0,0 +1,298 @@
+// 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.entities;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@AutoValue
+public abstract class LabelType {
+  public static final boolean DEF_ALLOW_POST_SUBMIT = true;
+  public static final boolean DEF_CAN_OVERRIDE = true;
+  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
+  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
+  public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
+  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
+  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) {
+    checkName(name);
+    List<LabelValue> values = new ArrayList<>(2);
+    values.add(LabelValue.create((short) 0, "Rejected"));
+    values.add(LabelValue.create((short) 1, "Approved"));
+    return create(name, values);
+  }
+
+  public static String checkName(String name) throws IllegalArgumentException {
+    checkNameInternal(name);
+    if ("SUBM".equals(name)) {
+      throw new IllegalArgumentException("Reserved label name \"" + name + "\"");
+    }
+    return name;
+  }
+
+  public static String checkNameInternal(String name) throws IllegalArgumentException {
+    if (name == null || name.isEmpty()) {
+      throw new IllegalArgumentException("Empty label name");
+    }
+    for (int i = 0; i < name.length(); i++) {
+      char c = name.charAt(i);
+      if ((i == 0 && c == '-')
+          || !((c >= 'a' && c <= 'z')
+              || (c >= 'A' && c <= 'Z')
+              || (c >= '0' && c <= '9')
+              || c == '-')) {
+        throw new IllegalArgumentException("Illegal label name \"" + name + "\"");
+      }
+    }
+    return name;
+  }
+
+  private static ImmutableList<LabelValue> sortValues(List<LabelValue> values) {
+    if (values.isEmpty()) {
+      return ImmutableList.of();
+    }
+    values = values.stream().sorted(comparing(LabelValue::getValue)).collect(toList());
+    short v = values.get(0).getValue();
+    short i = 0;
+    ImmutableList.Builder<LabelValue> result = ImmutableList.builder();
+    // Fill in any missing values with empty text.
+    while (i < values.size()) {
+      while (v < values.get(i).getValue()) {
+        result.add(LabelValue.create(v++, ""));
+      }
+      v++;
+      result.add(values.get(i++));
+    }
+    return result.build();
+  }
+
+  public abstract String getName();
+
+  public abstract LabelFunction getFunction();
+
+  public abstract boolean isCopyAnyScore();
+
+  public abstract boolean isCopyMinScore();
+
+  public abstract boolean isCopyMaxScore();
+
+  public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
+
+  public abstract boolean isCopyAllScoresOnTrivialRebase();
+
+  public abstract boolean isCopyAllScoresIfNoCodeChange();
+
+  public abstract boolean isCopyAllScoresIfNoChange();
+
+  public abstract ImmutableList<Short> getCopyValues();
+
+  public abstract boolean isAllowPostSubmit();
+
+  public abstract boolean isIgnoreSelfApproval();
+
+  public abstract short getDefaultValue();
+
+  public abstract ImmutableList<LabelValue> getValues();
+
+  public abstract short getMaxNegative();
+
+  public abstract short getMaxPositive();
+
+  public abstract boolean isCanOverride();
+
+  @Nullable
+  public abstract ImmutableList<String> getRefPatterns();
+
+  public abstract ImmutableMap<Short, LabelValue> getByValue();
+
+  public static LabelType create(String name, List<LabelValue> valueList) {
+    return LabelType.builder(name, valueList).build();
+  }
+
+  public static LabelType.Builder builder(String name, List<LabelValue> valueList) {
+    return (new AutoValue_LabelType.Builder())
+        .setName(name)
+        .setValues(valueList)
+        .setDefaultValue((short) 0)
+        .setFunction(LabelFunction.MAX_WITH_BLOCK)
+        .setMaxNegative(Short.MIN_VALUE)
+        .setMaxPositive(Short.MAX_VALUE)
+        .setCanOverride(DEF_CAN_OVERRIDE)
+        .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
+        .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+        .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+        .setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+        .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);
+  }
+
+  public boolean matches(PatchSetApproval psa) {
+    return psa.labelId().get().equalsIgnoreCase(getName());
+  }
+
+  public LabelValue getMin() {
+    if (getValues().isEmpty()) {
+      return null;
+    }
+    return getValues().get(0);
+  }
+
+  public LabelValue getMax() {
+    if (getValues().isEmpty()) {
+      return null;
+    }
+    return getValues().get(getValues().size() - 1);
+  }
+
+  public boolean isMaxNegative(PatchSetApproval ca) {
+    return getMaxNegative() == ca.value();
+  }
+
+  public boolean isMaxPositive(PatchSetApproval ca) {
+    return getMaxPositive() == ca.value();
+  }
+
+  public LabelValue getValue(short value) {
+    return getByValue().get(value);
+  }
+
+  public LabelValue getValue(PatchSetApproval ca) {
+    return getByValue().get(ca.value());
+  }
+
+  public LabelId getLabelId() {
+    return LabelId.create(getName());
+  }
+
+  @Override
+  public final String toString() {
+    StringBuilder sb = new StringBuilder(getName()).append('[');
+    LabelValue min = getMin();
+    LabelValue max = getMax();
+    if (min != null && max != null) {
+      sb.append(
+          new PermissionRange(Permission.forLabel(getName()), min.getValue(), max.getValue())
+              .toString()
+              .trim());
+    } else if (min != null) {
+      sb.append(min.formatValue().trim());
+    } else if (max != null) {
+      sb.append(max.formatValue().trim());
+    }
+    sb.append(']');
+    return sb.toString();
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(String name);
+
+    public abstract Builder setFunction(LabelFunction function);
+
+    public abstract Builder setCanOverride(boolean canOverride);
+
+    public abstract Builder setAllowPostSubmit(boolean allowPostSubmit);
+
+    public abstract Builder setIgnoreSelfApproval(boolean ignoreSelfApproval);
+
+    public abstract Builder setRefPatterns(@Nullable List<String> refPatterns);
+
+    public abstract Builder setValues(List<LabelValue> values);
+
+    public abstract Builder setDefaultValue(short defaultValue);
+
+    public abstract Builder setCopyAnyScore(boolean copyAnyScore);
+
+    public abstract Builder setCopyMinScore(boolean copyMinScore);
+
+    public abstract Builder setCopyMaxScore(boolean copyMaxScore);
+
+    public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
+        boolean copyAllScoresOnMergeFirstParentUpdate);
+
+    public abstract Builder setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase);
+
+    public abstract Builder setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange);
+
+    public abstract Builder setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange);
+
+    public abstract Builder setCopyValues(Collection<Short> copyValues);
+
+    public abstract Builder setMaxNegative(short maxNegative);
+
+    public abstract Builder setMaxPositive(short maxPositive);
+
+    public abstract ImmutableList<LabelValue> getValues();
+
+    protected abstract String getName();
+
+    protected abstract ImmutableList<Short> getCopyValues();
+
+    protected abstract Builder setByValue(ImmutableMap<Short, LabelValue> byValue);
+
+    @Nullable
+    protected abstract ImmutableList<String> getRefPatterns();
+
+    protected abstract LabelType autoBuild();
+
+    public LabelType build() throws IllegalArgumentException {
+      setName(checkName(getName()));
+      if (getRefPatterns() == null || getRefPatterns().isEmpty()) {
+        // Empty to null
+        setRefPatterns(null);
+      }
+
+      List<LabelValue> valueList = sortValues(getValues());
+      setValues(valueList);
+      if (!valueList.isEmpty()) {
+        if (valueList.get(0).getValue() < 0) {
+          setMaxNegative(valueList.get(0).getValue());
+        }
+        if (valueList.get(valueList.size() - 1).getValue() > 0) {
+          setMaxPositive(valueList.get(valueList.size() - 1).getValue());
+        }
+      }
+
+      ImmutableMap.Builder<Short, LabelValue> byValue = ImmutableMap.builder();
+      for (LabelValue v : valueList) {
+        byValue.put(v.getValue(), v);
+      }
+      setByValue(byValue.build());
+
+      setCopyValues(ImmutableList.sortedCopyOf(getCopyValues()));
+
+      return autoBuild();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
new file mode 100644
index 0000000..1c38c59
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class LabelTypes {
+  protected List<LabelType> labelTypes;
+  private transient volatile Map<String, LabelType> byLabel;
+  private transient volatile Map<String, Integer> positions;
+
+  protected LabelTypes() {}
+
+  public LabelTypes(List<? extends LabelType> approvals) {
+    labelTypes = Collections.unmodifiableList(new ArrayList<>(approvals));
+  }
+
+  public List<LabelType> getLabelTypes() {
+    return labelTypes;
+  }
+
+  public LabelType byLabel(LabelId labelId) {
+    return byLabel().get(labelId.get().toLowerCase());
+  }
+
+  public LabelType byLabel(String labelName) {
+    return byLabel().get(labelName.toLowerCase());
+  }
+
+  private Map<String, LabelType> byLabel() {
+    if (byLabel == null) {
+      synchronized (this) {
+        if (byLabel == null) {
+          Map<String, LabelType> l = new HashMap<>();
+          if (labelTypes != null) {
+            for (LabelType t : labelTypes) {
+              l.put(t.getName().toLowerCase(), t);
+            }
+          }
+          byLabel = l;
+        }
+      }
+    }
+    return byLabel;
+  }
+
+  @Override
+  public String toString() {
+    return labelTypes.toString();
+  }
+
+  public Comparator<String> nameComparator() {
+    final Map<String, Integer> positions = positions();
+    return new Comparator<String>() {
+      @Override
+      public int compare(String left, String right) {
+        int lp = position(left);
+        int rp = position(right);
+        int cmp = lp - rp;
+        if (cmp == 0) {
+          cmp = left.compareTo(right);
+        }
+        return cmp;
+      }
+
+      private int position(String name) {
+        Integer p = positions.get(name);
+        return p != null ? p : positions.size();
+      }
+    };
+  }
+
+  private Map<String, Integer> positions() {
+    if (positions == null) {
+      synchronized (this) {
+        if (positions == null) {
+          Map<String, Integer> p = new HashMap<>();
+          if (labelTypes != null) {
+            int i = 0;
+            for (LabelType t : labelTypes) {
+              p.put(t.getName(), i++);
+            }
+          }
+          positions = p;
+        }
+      }
+    }
+    return positions;
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelValue.java b/java/com/google/gerrit/entities/LabelValue.java
new file mode 100644
index 0000000..ec5a37e
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelValue.java
@@ -0,0 +1,55 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+
+@AutoValue
+public abstract class LabelValue {
+  public static String formatValue(short value) {
+    if (value < 0) {
+      return Short.toString(value);
+    } else if (value == 0) {
+      return " 0";
+    } else {
+      return "+" + value;
+    }
+  }
+
+  public abstract short getValue();
+
+  public abstract String getText();
+
+  public static LabelValue create(short value, String text) {
+    return new AutoValue_LabelValue(value, text);
+  }
+
+  public String formatValue() {
+    return formatValue(getValue());
+  }
+
+  public String format() {
+    StringBuilder sb = new StringBuilder(formatValue());
+    if (!getText().isEmpty()) {
+      sb.append(' ').append(getText());
+    }
+    return sb.toString();
+  }
+
+  @Override
+  public final String toString() {
+    return format();
+  }
+}
diff --git a/java/com/google/gerrit/entities/NotifyConfig.java b/java/com/google/gerrit/entities/NotifyConfig.java
new file mode 100644
index 0000000..17da81f
--- /dev/null
+++ b/java/com/google/gerrit/entities/NotifyConfig.java
@@ -0,0 +1,121 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import java.util.EnumSet;
+import java.util.Set;
+
+@AutoValue
+public abstract class NotifyConfig implements Comparable<NotifyConfig> {
+  public enum Header {
+    TO,
+    CC,
+    BCC
+  }
+
+  public enum NotifyType {
+    // sort by name, except 'ALL' which should stay last
+    ABANDONED_CHANGES,
+    ALL_COMMENTS,
+    NEW_CHANGES,
+    NEW_PATCHSETS,
+    SUBMITTED_CHANGES,
+
+    ALL
+  }
+
+  public abstract String getName();
+
+  public abstract ImmutableSet<NotifyType> getNotify();
+
+  @Nullable
+  public abstract String getFilter();
+
+  @Nullable
+  public abstract Header getHeader();
+
+  public abstract ImmutableSet<GroupReference> getGroups();
+
+  public abstract ImmutableSet<Address> getAddresses();
+
+  public boolean isNotify(NotifyType type) {
+    return getNotify().contains(type) || getNotify().contains(NotifyType.ALL);
+  }
+
+  public static Builder builder() {
+    return new AutoValue_NotifyConfig.Builder()
+        .setNotify(ImmutableSet.copyOf(EnumSet.of(NotifyType.ALL)));
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(String name);
+
+    public abstract Builder setNotify(Set<NotifyType> newTypes);
+
+    public abstract Builder setFilter(@Nullable String filter);
+
+    public abstract Builder setHeader(Header hdr);
+
+    public Builder addGroup(GroupReference group) {
+      groupsBuilder().add(group);
+      return this;
+    }
+
+    public Builder addAddress(Address address) {
+      addressesBuilder().add(address);
+      return this;
+    }
+
+    protected abstract ImmutableSet.Builder<GroupReference> groupsBuilder();
+
+    protected abstract ImmutableSet.Builder<Address> addressesBuilder();
+
+    protected abstract NotifyConfig autoBuild();
+
+    protected abstract String getFilter();
+
+    public NotifyConfig build() {
+      if ("*".equals(getFilter())) {
+        setFilter(null);
+      } else {
+        setFilter(Strings.emptyToNull(getFilter()));
+      }
+      return autoBuild();
+    }
+  }
+
+  @Override
+  public final int compareTo(NotifyConfig o) {
+    return getName().compareTo(o.getName());
+  }
+
+  @Override
+  public final int hashCode() {
+    return getName().hashCode();
+  }
+
+  @Override
+  public final boolean equals(Object obj) {
+    if (obj instanceof NotifyConfig) {
+      return compareTo((NotifyConfig) obj) == 0;
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index e47d197..e6b2167 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -36,6 +36,12 @@
   public static final String MERGE_LIST = "/MERGE_LIST";
 
   /**
+   * Magical file name which doesn't represent a file. Used specifically for patchset-level
+   * comments.
+   */
+  public static final String PATCHSET_LEVEL = "/PATCHSET_LEVEL";
+
+  /**
    * Checks if the given path represents a magic file. A magic file is a generated file that is
    * automatically included into changes. It does not exist in the commit of the patch set.
    *
@@ -43,7 +49,7 @@
    * @return {@code true} if the path represents a magic file, otherwise {@code false}.
    */
   public static boolean isMagic(String path) {
-    return COMMIT_MSG.equals(path) || MERGE_LIST.equals(path);
+    return COMMIT_MSG.equals(path) || MERGE_LIST.equals(path) || PATCHSET_LEVEL.equals(path);
   }
 
   public static Key key(PatchSet.Id patchSetId, String fileName) {
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 4a33bd7..5c8f7eb 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -21,7 +21,6 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Streams;
 import com.google.common.primitives.Ints;
 import java.sql.Timestamp;
 import java.util.List;
@@ -55,7 +54,7 @@
   }
 
   public static ImmutableList<String> splitGroups(String joinedGroups) {
-    return Streams.stream(Splitter.on(',').split(joinedGroups)).collect(toImmutableList());
+    return Splitter.on(',').splitToStream(joinedGroups).collect(toImmutableList());
   }
 
   public static Id id(Change.Id changeId, int id) {
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
new file mode 100644
index 0000000..3f04fa5
--- /dev/null
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -0,0 +1,293 @@
+// 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.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.Consumer;
+
+/** A single permission within an {@link AccessSection} of a project. */
+@AutoValue
+public abstract class Permission implements Comparable<Permission> {
+  public static final String ABANDON = "abandon";
+  public static final String ADD_PATCH_SET = "addPatchSet";
+  public static final String CREATE = "create";
+  public static final String CREATE_SIGNED_TAG = "createSignedTag";
+  public static final String CREATE_TAG = "createTag";
+  public static final String DELETE = "delete";
+  public static final String DELETE_CHANGES = "deleteChanges";
+  public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
+  public static final String EDIT_ASSIGNEE = "editAssignee";
+  public static final String EDIT_HASHTAGS = "editHashtags";
+  public static final String EDIT_TOPIC_NAME = "editTopicName";
+  public static final String FORGE_AUTHOR = "forgeAuthor";
+  public static final String FORGE_COMMITTER = "forgeCommitter";
+  public static final String FORGE_SERVER = "forgeServerAsCommitter";
+  public static final String LABEL = "label-";
+  public static final String LABEL_AS = "labelAs-";
+  public static final String OWNER = "owner";
+  public static final String PUSH = "push";
+  public static final String PUSH_MERGE = "pushMerge";
+  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";
+  public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
+
+  public static final boolean DEF_EXCLUSIVE_GROUP = false;
+
+  private static final List<String> NAMES_LC;
+  private static final int LABEL_INDEX;
+  private static final int LABEL_AS_INDEX;
+
+  static {
+    NAMES_LC = new ArrayList<>();
+    NAMES_LC.add(ABANDON.toLowerCase());
+    NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
+    NAMES_LC.add(CREATE.toLowerCase());
+    NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
+    NAMES_LC.add(CREATE_TAG.toLowerCase());
+    NAMES_LC.add(DELETE.toLowerCase());
+    NAMES_LC.add(DELETE_CHANGES.toLowerCase());
+    NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
+    NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
+    NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
+    NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
+    NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
+    NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
+    NAMES_LC.add(FORGE_SERVER.toLowerCase());
+    NAMES_LC.add(LABEL.toLowerCase());
+    NAMES_LC.add(LABEL_AS.toLowerCase());
+    NAMES_LC.add(OWNER.toLowerCase());
+    NAMES_LC.add(PUSH.toLowerCase());
+    NAMES_LC.add(PUSH_MERGE.toLowerCase());
+    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());
+    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
+
+    LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
+    LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+  }
+
+  /** @return true if the name is recognized as a permission name. */
+  public static boolean isPermission(String varName) {
+    return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
+  }
+
+  public static boolean hasRange(String varName) {
+    return isLabel(varName) || isLabelAs(varName);
+  }
+
+  /** @return true if the permission name is actually for a review label. */
+  public static boolean isLabel(String varName) {
+    return varName.startsWith(LABEL) && LABEL.length() < varName.length();
+  }
+
+  /** @return true if the permission is for impersonated review labels. */
+  public static boolean isLabelAs(String var) {
+    return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
+  }
+
+  /** @return permission name for the given review label. */
+  public static String forLabel(String labelName) {
+    return LABEL + labelName;
+  }
+
+  /** @return permission name to apply a label for another user. */
+  public static String forLabelAs(String labelName) {
+    return LABEL_AS + labelName;
+  }
+
+  public static String extractLabel(String varName) {
+    if (isLabel(varName)) {
+      return varName.substring(LABEL.length());
+    } else if (isLabelAs(varName)) {
+      return varName.substring(LABEL_AS.length());
+    }
+    return null;
+  }
+
+  public static boolean canBeOnAllProjects(String ref, String permissionName) {
+    if (AccessSection.ALL.equals(ref)) {
+      return !OWNER.equals(permissionName);
+    }
+    return true;
+  }
+
+  public abstract String getName();
+
+  protected abstract boolean isExclusiveGroup();
+
+  public abstract ImmutableList<PermissionRule> getRules();
+
+  public static Builder builder(String name) {
+    return new AutoValue_Permission.Builder()
+        .setName(name)
+        .setExclusiveGroup(DEF_EXCLUSIVE_GROUP)
+        .setRules(ImmutableList.of());
+  }
+
+  public static Permission create(String name) {
+    return builder(name).build();
+  }
+
+  public String getLabel() {
+    return extractLabel(getName());
+  }
+
+  public boolean getExclusiveGroup() {
+    // Only permit exclusive group behavior on non OWNER permissions,
+    // otherwise an owner might lose access to a delegated subspace.
+    //
+    return isExclusiveGroup() && !OWNER.equals(getName());
+  }
+
+  @Nullable
+  public PermissionRule getRule(GroupReference group) {
+    for (PermissionRule r : getRules()) {
+      if (sameGroup(r, group)) {
+        return r;
+      }
+    }
+
+    return null;
+  }
+
+  private static boolean sameGroup(PermissionRule rule, GroupReference group) {
+    if (group.getUUID() != null && rule.getGroup().getUUID() != null) {
+      return group.getUUID().equals(rule.getGroup().getUUID());
+    } else if (group.getName() != null && rule.getGroup().getName() != null) {
+      return group.getName().equals(rule.getGroup().getName());
+    } else {
+      return false;
+    }
+  }
+
+  @Override
+  public final int compareTo(Permission b) {
+    int cmp = index(this) - index(b);
+    if (cmp == 0) {
+      cmp = getName().compareTo(b.getName());
+    }
+    return cmp;
+  }
+
+  private static int index(Permission a) {
+    if (isLabel(a.getName())) {
+      return LABEL_INDEX;
+    } else if (isLabelAs(a.getName())) {
+      return LABEL_AS_INDEX;
+    }
+
+    int index = NAMES_LC.indexOf(a.getName().toLowerCase());
+    return 0 <= index ? index : NAMES_LC.size();
+  }
+
+  @Override
+  public final String toString() {
+    StringBuilder bldr = new StringBuilder();
+    bldr.append(getName()).append(" ");
+    if (isExclusiveGroup()) {
+      bldr.append("[exclusive] ");
+    }
+    bldr.append("[");
+    Iterator<PermissionRule> it = getRules().iterator();
+    while (it.hasNext()) {
+      bldr.append(it.next());
+      if (it.hasNext()) {
+        bldr.append(", ");
+      }
+    }
+    bldr.append("]");
+    return bldr.toString();
+  }
+
+  protected abstract Builder autoToBuilder();
+
+  public Builder toBuilder() {
+    Builder b = autoToBuilder();
+    getRules().stream().map(PermissionRule::toBuilder).forEach(r -> b.add(r));
+    return b;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    private final List<PermissionRule.Builder> rulesBuilders;
+
+    Builder() {
+      rulesBuilders = new ArrayList<>();
+    }
+
+    public abstract Builder setName(String value);
+
+    public abstract String getName();
+
+    public abstract Builder setExclusiveGroup(boolean value);
+
+    public Builder modifyRules(Consumer<List<PermissionRule.Builder>> modification) {
+      modification.accept(rulesBuilders);
+      return this;
+    }
+
+    public Builder add(PermissionRule.Builder rule) {
+      return modifyRules(r -> r.add(rule));
+    }
+
+    public Builder remove(PermissionRule rule) {
+      if (rule != null) {
+        return removeRule(rule.getGroup());
+      }
+      return this;
+    }
+
+    public Builder removeRule(GroupReference group) {
+      return modifyRules(rules -> rules.removeIf(rule -> sameGroup(rule.build(), group)));
+    }
+
+    public Builder clearRules() {
+      return modifyRules(r -> r.clear());
+    }
+
+    public Permission build() {
+      setRules(
+          rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
+      return autoBuild();
+    }
+
+    public List<PermissionRule.Builder> getRulesBuilders() {
+      return rulesBuilders;
+    }
+
+    protected abstract ImmutableList<PermissionRule> getRules();
+
+    protected abstract Builder setRules(ImmutableList<PermissionRule> rules);
+
+    protected abstract Permission autoBuild();
+  }
+}
diff --git a/java/com/google/gerrit/entities/PermissionRange.java b/java/com/google/gerrit/entities/PermissionRange.java
new file mode 100644
index 0000000..fa9f4c2
--- /dev/null
+++ b/java/com/google/gerrit/entities/PermissionRange.java
@@ -0,0 +1,144 @@
+// 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.entities;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a closed interval [min, max] with a name. The special value [0, 0] is understood to be
+ * the empty range.
+ */
+public class PermissionRange implements Comparable<PermissionRange> {
+  public static class WithDefaults extends PermissionRange {
+    protected int defaultMin;
+    protected int defaultMax;
+
+    protected WithDefaults() {}
+
+    public WithDefaults(String name, int min, int max, int defMin, int defMax) {
+      super(name, min, max);
+      setDefaultRange(defMin, defMax);
+    }
+
+    public int getDefaultMin() {
+      return defaultMin;
+    }
+
+    public int getDefaultMax() {
+      return defaultMax;
+    }
+
+    public void setDefaultRange(int min, int max) {
+      defaultMin = min;
+      defaultMax = max;
+    }
+
+    /** @return all values between {@link #getMin()} and {@link #getMax()} */
+    public List<Integer> getValuesAsList() {
+      ArrayList<Integer> r = new ArrayList<>(getRangeSize());
+      for (int i = min; i <= max; i++) {
+        r.add(i);
+      }
+      return r;
+    }
+
+    /** @return number of values between {@link #getMin()} and {@link #getMax()} */
+    public int getRangeSize() {
+      return max - min;
+    }
+  }
+
+  protected String name;
+  protected int min;
+  protected int max;
+
+  protected PermissionRange() {}
+
+  public PermissionRange(String name, int min, int max) {
+    this.name = name;
+
+    if (min <= max) {
+      this.min = min;
+      this.max = max;
+    } else {
+      this.min = 0;
+      this.max = 0;
+    }
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public boolean isLabel() {
+    return Permission.isLabel(getName());
+  }
+
+  public String getLabel() {
+    return Permission.extractLabel(getName());
+  }
+
+  public int getMin() {
+    return min;
+  }
+
+  public int getMax() {
+    return max;
+  }
+
+  /** True if the value is within the range. */
+  public boolean contains(int value) {
+    return getMin() <= value && value <= getMax();
+  }
+
+  /** Normalize the value to fit within the bounds of the range. */
+  public int squash(int value) {
+    return Math.min(Math.max(getMin(), value), getMax());
+  }
+
+  /** True both {@link #getMin()} and {@link #getMax()} are 0. */
+  public boolean isEmpty() {
+    return getMin() == 0 && getMax() == 0;
+  }
+
+  @Override
+  public int compareTo(PermissionRange o) {
+    return getName().compareTo(o.getName());
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder r = new StringBuilder();
+    if (getMin() < 0 && getMax() == 0) {
+      r.append(getMin());
+      r.append(' ');
+    } else {
+      if (getMin() != getMax()) {
+        if (0 <= getMin()) {
+          r.append('+');
+        }
+        r.append(getMin());
+        r.append("..");
+      }
+      if (0 <= getMax()) {
+        r.append('+');
+      }
+      r.append(getMax());
+      r.append(' ');
+    }
+    return r.toString();
+  }
+}
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java
new file mode 100644
index 0000000..9a2d31e
--- /dev/null
+++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -0,0 +1,272 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+
+@AutoValue
+public abstract class PermissionRule implements Comparable<PermissionRule> {
+  public static final boolean DEF_FORCE = false;
+
+  public enum Action {
+    ALLOW,
+    DENY,
+    BLOCK,
+
+    INTERACTIVE,
+    BATCH
+  }
+
+  public abstract Action getAction();
+
+  public abstract boolean getForce();
+
+  public abstract int getMin();
+
+  public abstract int getMax();
+
+  public abstract GroupReference getGroup();
+
+  public static PermissionRule.Builder builder(GroupReference group) {
+    return builder().setGroup(group);
+  }
+
+  public static PermissionRule create(GroupReference group) {
+    return builder().setGroup(group).build();
+  }
+
+  protected static Builder builder() {
+    return new AutoValue_PermissionRule.Builder()
+        .setMin(0)
+        .setMax(0)
+        .setAction(Action.ALLOW)
+        .setForce(DEF_FORCE);
+  }
+
+  static PermissionRule merge(PermissionRule src, PermissionRule dest) {
+    PermissionRule.Builder result = dest.toBuilder();
+    if (dest.getAction() != src.getAction()) {
+      if (dest.getAction() == Action.BLOCK || src.getAction() == Action.BLOCK) {
+        result.setAction(Action.BLOCK);
+
+      } else if (dest.getAction() == Action.DENY || src.getAction() == Action.DENY) {
+        result.setAction(Action.DENY);
+
+      } else if (dest.getAction() == Action.BATCH || src.getAction() == Action.BATCH) {
+        result.setAction(Action.BATCH);
+      }
+    }
+
+    result.setForce(dest.getForce() || src.getForce());
+    result.setRange(Math.min(dest.getMin(), src.getMin()), Math.max(dest.getMax(), src.getMax()));
+    return result.build();
+  }
+
+  public boolean isDeny() {
+    return getAction() == Action.DENY;
+  }
+
+  public boolean isBlock() {
+    return getAction() == Action.BLOCK;
+  }
+
+  @Override
+  public int compareTo(PermissionRule o) {
+    int cmp = action(this) - action(o);
+    if (cmp == 0) {
+      cmp = range(o) - range(this);
+    }
+    if (cmp == 0) {
+      cmp = group(this).compareTo(group(o));
+    }
+    return cmp;
+  }
+
+  private static int action(PermissionRule a) {
+    switch (a.getAction()) {
+      case DENY:
+        return 0;
+      case ALLOW:
+      case BATCH:
+      case BLOCK:
+      case INTERACTIVE:
+      default:
+        return 1 + a.getAction().ordinal();
+    }
+  }
+
+  private static int range(PermissionRule a) {
+    return Math.abs(a.getMin()) + Math.abs(a.getMax());
+  }
+
+  private static String group(PermissionRule a) {
+    return a.getGroup().getName() != null ? a.getGroup().getName() : "";
+  }
+
+  @Override
+  public final String toString() {
+    return asString(true);
+  }
+
+  public String asString(boolean canUseRange) {
+    StringBuilder r = new StringBuilder();
+
+    switch (getAction()) {
+      case ALLOW:
+        break;
+
+      case DENY:
+        r.append("deny ");
+        break;
+
+      case BLOCK:
+        r.append("block ");
+        break;
+
+      case INTERACTIVE:
+        r.append("interactive ");
+        break;
+
+      case BATCH:
+        r.append("batch ");
+        break;
+    }
+
+    if (getForce()) {
+      r.append("+force ");
+    }
+
+    if (canUseRange && (getMin() != 0 || getMax() != 0)) {
+      if (0 <= getMin()) {
+        r.append('+');
+      }
+      r.append(getMin());
+      r.append("..");
+      if (0 <= getMax()) {
+        r.append('+');
+      }
+      r.append(getMax());
+      r.append(' ');
+    }
+
+    r.append(getGroup().toConfigValue());
+
+    return r.toString();
+  }
+
+  public static PermissionRule fromString(String src, boolean mightUseRange) {
+    final String orig = src;
+    final PermissionRule.Builder rule = PermissionRule.builder();
+
+    src = src.trim();
+
+    if (src.startsWith("deny ")) {
+      rule.setAction(Action.DENY);
+      src = src.substring("deny ".length()).trim();
+
+    } else if (src.startsWith("block ")) {
+      rule.setAction(Action.BLOCK);
+      src = src.substring("block ".length()).trim();
+
+    } else if (src.startsWith("interactive ")) {
+      rule.setAction(Action.INTERACTIVE);
+      src = src.substring("interactive ".length()).trim();
+
+    } else if (src.startsWith("batch ")) {
+      rule.setAction(Action.BATCH);
+      src = src.substring("batch ".length()).trim();
+    }
+
+    if (src.startsWith("+force ")) {
+      rule.setForce(true);
+      src = src.substring("+force ".length()).trim();
+    }
+
+    if (mightUseRange && !GroupReference.isGroupReference(src)) {
+      int sp = src.indexOf(' ');
+      String range = src.substring(0, sp);
+
+      if (range.matches("^([+-]?\\d+)\\.\\.([+-]?\\d+)$")) {
+        int dotdot = range.indexOf("..");
+        int min = parseInt(range.substring(0, dotdot));
+        int max = parseInt(range.substring(dotdot + 2));
+        rule.setRange(min, max);
+      } else {
+        throw new IllegalArgumentException("Invalid range in rule: " + orig);
+      }
+
+      src = src.substring(sp + 1).trim();
+    }
+
+    String groupName = GroupReference.extractGroupName(src);
+    if (groupName != null) {
+      GroupReference group = GroupReference.create(groupName);
+      rule.setGroup(group);
+    } else {
+      throw new IllegalArgumentException("Rule must include group: " + orig);
+    }
+
+    return rule.build();
+  }
+
+  public boolean hasRange() {
+    return getMin() != 0 || getMax() != 0;
+  }
+
+  public static int parseInt(String value) {
+    if (value.startsWith("+")) {
+      value = value.substring(1);
+    }
+    return Integer.parseInt(value);
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public Builder setDeny() {
+      return setAction(Action.DENY);
+    }
+
+    public Builder setBlock() {
+      return setAction(Action.BLOCK);
+    }
+
+    public Builder setRange(int newMin, int newMax) {
+      if (newMax < newMin) {
+        setMin(newMax);
+        setMax(newMin);
+      } else {
+        setMin(newMin);
+        setMax(newMax);
+      }
+      return this;
+    }
+
+    public abstract Builder setAction(Action action);
+
+    public abstract Builder setGroup(GroupReference groupReference);
+
+    public abstract Builder setForce(boolean newForce);
+
+    public abstract Builder setMin(int min);
+
+    public abstract Builder setMax(int max);
+
+    public abstract GroupReference getGroup();
+
+    public abstract PermissionRule build();
+  }
+}
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 867b14d..ef3cbeb 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -16,6 +16,10 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -26,7 +30,8 @@
 import java.util.Optional;
 
 /** Projects match a source code repository managed by Gerrit */
-public final class Project {
+@AutoValue
+public abstract class Project {
   /** Default submit type for new projects. */
   public static final SubmitType DEFAULT_SUBMIT_TYPE = SubmitType.MERGE_IF_NECESSARY;
 
@@ -47,7 +52,10 @@
    * <p>Because of this unusual subclassing behavior, this class is not an {@code @AutoValue},
    * unlike other key types in this package. However, this is strictly an implementation detail; its
    * interface and semantics are otherwise analogous to the {@code @AutoValue} types.
+   *
+   * <p>This class is immutable and thread safe.
    */
+  @Immutable
   public static class NameKey implements Serializable, Comparable<NameKey> {
     private static final long serialVersionUID = 1L;
 
@@ -56,10 +64,6 @@
       return nameKey(KeyUtil.decode(str));
     }
 
-    public static String asStringOrNull(NameKey key) {
-      return key == null ? null : key.get();
-    }
-
     private final String name;
 
     protected NameKey(String name) {
@@ -72,140 +76,86 @@
 
     @Override
     public final int hashCode() {
-      return get().hashCode();
+      return name.hashCode();
     }
 
     @Override
     public final boolean equals(Object b) {
       if (b instanceof NameKey) {
-        return get().equals(((NameKey) b).get());
+        return name.equals(((NameKey) b).get());
       }
       return false;
     }
 
     @Override
     public final int compareTo(NameKey o) {
-      return get().compareTo(o.get());
+      return name.compareTo(o.get());
     }
 
     @Override
     public final String toString() {
-      return KeyUtil.encode(get());
+      return KeyUtil.encode(name);
     }
   }
 
-  protected NameKey name;
+  public abstract NameKey getNameKey();
 
-  protected String description;
+  @Nullable
+  public abstract String getDescription();
 
-  protected Map<BooleanProjectConfig, InheritableBoolean> booleanConfigs;
-
-  protected SubmitType submitType;
-
-  protected ProjectState state;
-
-  protected NameKey parent;
-
-  protected String maxObjectSizeLimit;
-
-  protected String defaultDashboardId;
-
-  protected String localDefaultDashboardId;
-
-  protected String configRefState;
-
-  protected Project() {}
-
-  public Project(Project.NameKey nameKey) {
-    name = nameKey;
-    submitType = SubmitType.MERGE_IF_NECESSARY;
-    state = ProjectState.ACTIVE;
-
-    booleanConfigs = new HashMap<>();
-    Arrays.stream(BooleanProjectConfig.values())
-        .forEach(c -> booleanConfigs.put(c, InheritableBoolean.INHERIT));
-  }
-
-  public Project.NameKey getNameKey() {
-    return name;
-  }
-
-  public String getName() {
-    return name != null ? name.get() : null;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String d) {
-    description = d;
-  }
-
-  public String getMaxObjectSizeLimit() {
-    return maxObjectSizeLimit;
-  }
-
-  public InheritableBoolean getBooleanConfig(BooleanProjectConfig config) {
-    return booleanConfigs.get(config);
-  }
-
-  public void setBooleanConfig(BooleanProjectConfig config, InheritableBoolean val) {
-    booleanConfigs.replace(config, val);
-  }
-
-  public void setMaxObjectSizeLimit(String limit) {
-    maxObjectSizeLimit = limit;
-  }
+  public abstract ImmutableMap<BooleanProjectConfig, InheritableBoolean> getBooleanConfigs();
 
   /**
    * Submit type as configured in {@code project.config}.
    *
    * <p>Does not take inheritance into account, i.e. may return {@link SubmitType#INHERIT}.
-   *
-   * @return submit type.
    */
-  public SubmitType getConfiguredSubmitType() {
-    return submitType;
-  }
+  public abstract SubmitType getSubmitType();
 
-  public void setSubmitType(SubmitType type) {
-    submitType = type;
-  }
-
-  public ProjectState getState() {
-    return state;
-  }
-
-  public void setState(ProjectState newState) {
-    state = newState;
-  }
-
-  public String getDefaultDashboard() {
-    return defaultDashboardId;
-  }
-
-  public void setDefaultDashboard(String defaultDashboardId) {
-    this.defaultDashboardId = defaultDashboardId;
-  }
-
-  public String getLocalDefaultDashboard() {
-    return localDefaultDashboardId;
-  }
-
-  public void setLocalDefaultDashboard(String localDefaultDashboardId) {
-    this.localDefaultDashboardId = localDefaultDashboardId;
-  }
+  public abstract ProjectState getState();
 
   /**
-   * Returns the name key of the parent project.
+   * Name key of the parent project.
    *
-   * @return name key of the parent project, {@code null} if this project is the wild project,
-   *     {@code null} or the name key of the wild project if this project is a direct child of the
-   *     wild project
+   * <p>{@code null} if this project is the wild project, {@code null} or the name key of the wild
+   * project if this project is a direct child of the wild project.
    */
-  public Project.NameKey getParent() {
-    return parent;
+  @Nullable
+  public abstract NameKey getParent();
+
+  @Nullable
+  public abstract String getMaxObjectSizeLimit();
+
+  @Nullable
+  public abstract String getDefaultDashboard();
+
+  @Nullable
+  public abstract String getLocalDefaultDashboard();
+
+  /** The {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
+  @Nullable
+  public abstract String getConfigRefState();
+
+  public static Builder builder(Project.NameKey nameKey) {
+    Builder builder =
+        new AutoValue_Project.Builder()
+            .setNameKey(nameKey)
+            .setSubmitType(SubmitType.MERGE_IF_NECESSARY)
+            .setState(ProjectState.ACTIVE);
+    ImmutableMap.Builder<BooleanProjectConfig, InheritableBoolean> booleans =
+        ImmutableMap.builder();
+    Arrays.stream(BooleanProjectConfig.values())
+        .forEach(b -> booleans.put(b, InheritableBoolean.INHERIT));
+    builder.setBooleanConfigs(booleans.build());
+    return builder;
+  }
+
+  public String getName() {
+    return getNameKey() != null ? getNameKey().get() : null;
+  }
+
+  public InheritableBoolean getBooleanConfig(BooleanProjectConfig config) {
+    return getBooleanConfigs().get(config);
   }
 
   /**
@@ -216,11 +166,11 @@
    *     project
    */
   public Project.NameKey getParent(Project.NameKey allProjectsName) {
-    if (parent != null) {
-      return parent;
+    if (getParent() != null) {
+      return getParent();
     }
 
-    if (name.equals(allProjectsName)) {
+    if (getNameKey().equals(allProjectsName)) {
       return null;
     }
 
@@ -228,29 +178,53 @@
   }
 
   public String getParentName() {
-    return parent != null ? parent.get() : null;
-  }
-
-  public void setParentName(String n) {
-    parent = n != null ? nameKey(n) : null;
-  }
-
-  public void setParentName(NameKey n) {
-    parent = n;
-  }
-
-  /** Returns the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
-  public String getConfigRefState() {
-    return configRefState;
-  }
-
-  /** Sets the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
-  public void setConfigRefState(String state) {
-    configRefState = state;
+    return getParent() != null ? getParent().get() : null;
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     return Optional.of(getName()).orElse("<null>");
   }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setDescription(String description);
+
+    public Builder setBooleanConfig(BooleanProjectConfig config, InheritableBoolean val) {
+      Map<BooleanProjectConfig, InheritableBoolean> map = new HashMap<>(getBooleanConfigs());
+      map.replace(config, val);
+      setBooleanConfigs(ImmutableMap.copyOf(map));
+      return this;
+    }
+
+    public abstract Builder setMaxObjectSizeLimit(String limit);
+
+    public abstract Builder setSubmitType(SubmitType type);
+
+    public abstract Builder setState(ProjectState newState);
+
+    public abstract Builder setDefaultDashboard(String defaultDashboardId);
+
+    public abstract Builder setLocalDefaultDashboard(String localDefaultDashboard);
+
+    public abstract Builder setParent(NameKey n);
+
+    public Builder setParent(String n) {
+      return setParent(n != null ? nameKey(n) : null);
+    }
+
+    /** Sets the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
+    public abstract Builder setConfigRefState(String state);
+
+    public abstract Project build();
+
+    protected abstract Builder setNameKey(Project.NameKey nameKey);
+
+    protected abstract ImmutableMap<BooleanProjectConfig, InheritableBoolean> getBooleanConfigs();
+
+    protected abstract Builder setBooleanConfigs(
+        ImmutableMap<BooleanProjectConfig, InheritableBoolean> booleanConfigs);
+  }
 }
diff --git a/java/com/google/gerrit/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
index 9256e79..e2e4114 100644
--- a/java/com/google/gerrit/entities/RobotComment.java
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -35,14 +35,15 @@
       String serverId,
       String robotId,
       String robotRunId) {
-    super(key, author, writtenOn, side, message, serverId, false);
+    super(key, author, writtenOn, side, message, serverId);
     this.robotId = robotId;
     this.robotRunId = robotRunId;
   }
 
   @Override
   public int getApproximateSize() {
-    int approximateSize = super.getApproximateSize() + nullableLength(robotId, robotRunId, url);
+    int approximateSize =
+        super.getCommentFieldApproximateSize() + nullableLength(robotId, robotRunId, url);
     approximateSize +=
         properties != null
             ? properties.entrySet().stream()
@@ -66,4 +67,23 @@
         .add("fixSuggestions", Objects.toString(fixSuggestions, ""))
         .toString();
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof RobotComment)) {
+      return false;
+    }
+    RobotComment c = (RobotComment) o;
+    return super.equals(o)
+        && Objects.equals(robotId, c.robotId)
+        && Objects.equals(robotRunId, c.robotRunId)
+        && Objects.equals(url, c.url)
+        && Objects.equals(properties, c.properties)
+        && Objects.equals(fixSuggestions, c.fixSuggestions);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), robotId, robotRunId, url, properties, fixSuggestions);
+  }
 }
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
new file mode 100644
index 0000000..f298782
--- /dev/null
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -0,0 +1,133 @@
+// 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.entities;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+
+/** Info about a single commentlink section in a config. */
+@AutoValue
+public abstract class StoredCommentLinkInfo {
+  public abstract String getName();
+
+  /** A regular expression to match for the commentlink to apply. */
+  @Nullable
+  public abstract String getMatch();
+
+  /** The link to replace the match with. This can only be set if html is {@code null}. */
+  @Nullable
+  public abstract String getLink();
+
+  /** The html to replace the match with. This can only be set if link is {@code null}. */
+  @Nullable
+  public abstract String getHtml();
+
+  /** Weather this comment link is active. {@code null} means true. */
+  @Nullable
+  public abstract Boolean getEnabled();
+
+  /** If set, {@link StoredCommentLinkInfo} has to be overridden to take any effect. */
+  public abstract boolean getOverrideOnly();
+
+  /**
+   * Creates an enabled {@link StoredCommentLinkInfo} that can be overridden but doesn't do anything
+   * on its own.
+   */
+  public static StoredCommentLinkInfo enabled(String name) {
+    return builder(name).setOverrideOnly(true).build();
+  }
+
+  /**
+   * Creates a disabled {@link StoredCommentLinkInfo} that can be overridden but doesn't do anything
+   * on it's own.
+   */
+  public static StoredCommentLinkInfo disabled(String name) {
+    return builder(name).setOverrideOnly(true).build();
+  }
+
+  /** Creates and returns a new {@link StoredCommentLinkInfo.Builder} instance. */
+  public static Builder builder(String name) {
+    checkArgument(name != null, "invalid commentlink.name");
+    return new AutoValue_StoredCommentLinkInfo.Builder().setName(name).setOverrideOnly(false);
+  }
+
+  /** Creates and returns a new {@link StoredCommentLinkInfo} instance with the same values. */
+  public static StoredCommentLinkInfo fromInfo(CommentLinkInfo src, Boolean enabled) {
+    return builder(src.name)
+        .setMatch(src.match)
+        .setLink(src.link)
+        .setHtml(src.html)
+        .setEnabled(enabled)
+        .setOverrideOnly(false)
+        .build();
+  }
+
+  /** Returns an {@link CommentLinkInfo} instance with the same values. */
+  public CommentLinkInfo toInfo() {
+    CommentLinkInfo info = new CommentLinkInfo();
+    info.name = getName();
+    info.match = getMatch();
+    info.link = getLink();
+    info.html = getHtml();
+    info.enabled = getEnabled();
+    return info;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(String value);
+
+    public abstract Builder setMatch(@Nullable String value);
+
+    public abstract Builder setLink(@Nullable String value);
+
+    public abstract Builder setHtml(@Nullable String value);
+
+    public abstract Builder setEnabled(@Nullable Boolean value);
+
+    public abstract Builder setOverrideOnly(boolean value);
+
+    public StoredCommentLinkInfo build() {
+      checkArgument(getName() != null, "invalid commentlink.name");
+      setLink(Strings.emptyToNull(getLink()));
+      setHtml(Strings.emptyToNull(getHtml()));
+      if (!getOverrideOnly()) {
+        checkArgument(
+            !Strings.isNullOrEmpty(getMatch()), "invalid commentlink.%s.match", getName());
+        checkArgument(
+            (getLink() != null && getHtml() == null) || (getLink() == null && getHtml() != null),
+            "commentlink.%s must have either link or html",
+            getName());
+      }
+      return autoBuild();
+    }
+
+    protected abstract StoredCommentLinkInfo autoBuild();
+
+    protected abstract String getName();
+
+    protected abstract String getMatch();
+
+    protected abstract String getLink();
+
+    protected abstract String getHtml();
+
+    protected abstract boolean getOverrideOnly();
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
new file mode 100644
index 0000000..67c6007
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -0,0 +1,184 @@
+// 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.entities;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+/** Describes the state and edits required to submit a change. */
+public class SubmitRecord {
+  public static boolean allRecordsOK(Collection<SubmitRecord> in) {
+    if (in == null || in.isEmpty()) {
+      // If the list is null or empty, it means that this Gerrit installation does not
+      // have any form of validation rules.
+      // Hence, the permission system should be used to determine if the change can be merged
+      // or not.
+      return true;
+    }
+
+    // The change can be submitted, unless at least one plugin prevents it.
+    return in.stream().map(SubmitRecord::status).allMatch(SubmitRecord.Status::allowsSubmission);
+  }
+
+  public enum Status {
+    // NOTE: These values are persisted in the index, so deleting or changing
+    // the name of any values requires a schema upgrade.
+
+    /** The change is ready for submission. */
+    OK,
+
+    /** Something is preventing this change from being submitted. */
+    NOT_READY,
+
+    /** The change has been closed. */
+    CLOSED,
+
+    /** The change was submitted bypassing submit rules. */
+    FORCED,
+
+    /**
+     * An internal server error occurred preventing computation.
+     *
+     * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
+     */
+    RULE_ERROR;
+
+    private boolean allowsSubmission() {
+      return this == OK || this == FORCED;
+    }
+  }
+
+  public Status status;
+  public List<Label> labels;
+  public List<SubmitRequirement> requirements;
+  public String errorMessage;
+
+  public static class Label {
+    public enum Status {
+      // NOTE: These values are persisted in the index, so deleting or changing
+      // the name of any values requires a schema upgrade.
+
+      /**
+       * This label provides what is necessary for submission.
+       *
+       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
+       * to the change.
+       */
+      OK,
+
+      /**
+       * This label prevents the change from being submitted.
+       *
+       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
+       * to the change.
+       */
+      REJECT,
+
+      /** The label is required for submission, but has not been satisfied. */
+      NEED,
+
+      /**
+       * The label may be set, but it's neither necessary for submission nor does it block
+       * submission if set.
+       */
+      MAY,
+
+      /**
+       * 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
+    }
+
+    public String label;
+    public Status status;
+    public Account.Id appliedBy;
+
+    @Override
+    public String toString() {
+      StringBuilder sb = new StringBuilder();
+      sb.append(label).append(": ").append(status);
+      if (appliedBy != null) {
+        sb.append(" by ").append(appliedBy);
+      }
+      return sb.toString();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Label) {
+        Label l = (Label) o;
+        return Objects.equals(label, l.label)
+            && Objects.equals(status, l.status)
+            && Objects.equals(appliedBy, l.appliedBy);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(label, status, appliedBy);
+    }
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(status);
+    if (status == Status.RULE_ERROR && errorMessage != null) {
+      sb.append('(').append(errorMessage).append(')');
+    }
+    sb.append('[');
+    if (labels != null) {
+      String delimiter = "";
+      for (Label label : labels) {
+        sb.append(delimiter).append(label);
+        delimiter = ", ";
+      }
+    }
+    sb.append("],[");
+    if (requirements != null) {
+      String delimiter = "";
+      for (SubmitRequirement requirement : requirements) {
+        sb.append(delimiter).append(requirement);
+        delimiter = ", ";
+      }
+    }
+    sb.append(']');
+    return sb.toString();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof SubmitRecord) {
+      SubmitRecord r = (SubmitRecord) o;
+      return Objects.equals(status, r.status)
+          && Objects.equals(labels, r.labels)
+          && Objects.equals(errorMessage, r.errorMessage)
+          && Objects.equals(requirements, r.requirements);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(status, labels, errorMessage, requirements);
+  }
+
+  private Status status() {
+    return status;
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
new file mode 100644
index 0000000..f9301a4
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -0,0 +1,60 @@
+// 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.entities;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+
+/** Describes a requirement to submit a change. */
+@AutoValue
+@AutoValue.CopyAnnotations
+public abstract class SubmitRequirement {
+  private static final CharMatcher TYPE_MATCHER =
+      CharMatcher.inRange('a', 'z')
+          .or(CharMatcher.inRange('A', 'Z'))
+          .or(CharMatcher.inRange('0', '9'))
+          .or(CharMatcher.anyOf("-_"));
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setType(String value);
+
+    public abstract Builder setFallbackText(String value);
+
+    public SubmitRequirement build() {
+      SubmitRequirement requirement = autoBuild();
+      checkState(
+          validateType(requirement.type()),
+          "SubmitRequirement's type contains non alphanumerical symbols.");
+      return requirement;
+    }
+
+    abstract SubmitRequirement autoBuild();
+  }
+
+  public abstract String fallbackText();
+
+  public abstract String type();
+
+  public static Builder builder() {
+    return new AutoValue_SubmitRequirement.Builder();
+  }
+
+  private static boolean validateType(String type) {
+    return TYPE_MATCHER.matchesAllOf(type);
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitTypeRecord.java b/java/com/google/gerrit/entities/SubmitTypeRecord.java
new file mode 100644
index 0000000..492d637
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitTypeRecord.java
@@ -0,0 +1,77 @@
+// 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.entities;
+
+import com.google.gerrit.extensions.client.SubmitType;
+
+/** Describes the submit type for a change. */
+public class SubmitTypeRecord {
+  public enum Status {
+    /** The type was computed successfully */
+    OK,
+
+    /**
+     * An internal server error occurred preventing computation.
+     *
+     * <p>Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
+     */
+    RULE_ERROR
+  }
+
+  public static SubmitTypeRecord OK(SubmitType type) {
+    return new SubmitTypeRecord(Status.OK, type, null);
+  }
+
+  public static SubmitTypeRecord error(String err) {
+    return new SubmitTypeRecord(SubmitTypeRecord.Status.RULE_ERROR, null, err);
+  }
+
+  /** Status enum value of the record. */
+  public final Status status;
+
+  /** Submit type of the record; never null if {@link #status} is {@code OK}. */
+  public final SubmitType type;
+
+  /** Submit type of the record; always null if {@link #status} is {@code OK}. */
+  public final String errorMessage;
+
+  private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
+    if (type == SubmitType.INHERIT) {
+      throw new IllegalArgumentException("Cannot output submit type " + type);
+    }
+    this.status = status;
+    this.type = type;
+    this.errorMessage = errorMessage;
+  }
+
+  public boolean isOk() {
+    return status == Status.OK;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(status);
+    if (status == Status.RULE_ERROR && errorMessage != null) {
+      sb.append(" (").append(errorMessage).append(")");
+    }
+    if (type != null) {
+      sb.append('[');
+      sb.append(type.name());
+      sb.append(']');
+    }
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubscribeSection.java b/java/com/google/gerrit/entities/SubscribeSection.java
new file mode 100644
index 0000000..b95517c
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubscribeSection.java
@@ -0,0 +1,162 @@
+// 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.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.RefSpec;
+
+/** Portion of a {@link Project} describing superproject subscription rules. */
+@AutoValue
+public abstract class SubscribeSection {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public abstract Project.NameKey project();
+
+  protected abstract ImmutableList<RefSpec> matchingRefSpecs();
+
+  protected abstract ImmutableList<RefSpec> multiMatchRefSpecs();
+
+  public static Builder builder(Project.NameKey project) {
+    return new AutoValue_SubscribeSection.Builder().project(project);
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder project(Project.NameKey project);
+
+    abstract ImmutableList.Builder<RefSpec> matchingRefSpecsBuilder();
+
+    abstract ImmutableList.Builder<RefSpec> multiMatchRefSpecsBuilder();
+
+    public Builder addMatchingRefSpec(String matchingRefSpec) {
+      matchingRefSpecsBuilder()
+          .add(new RefSpec(matchingRefSpec, RefSpec.WildcardMode.REQUIRE_MATCH));
+      return this;
+    }
+
+    public Builder addMultiMatchRefSpec(String multiMatchRefSpec) {
+      multiMatchRefSpecsBuilder()
+          .add(new RefSpec(multiMatchRefSpec, RefSpec.WildcardMode.ALLOW_MISMATCH));
+      return this;
+    }
+
+    public abstract SubscribeSection build();
+  }
+
+  /**
+   * Determines if the <code>branch</code> could trigger a superproject update as allowed via this
+   * subscribe section.
+   *
+   * @param branch the branch to check
+   * @return if the branch could trigger a superproject update
+   */
+  public boolean appliesTo(BranchNameKey branch) {
+    for (RefSpec r : matchingRefSpecs()) {
+      if (r.matchSource(branch.branch())) {
+        return true;
+      }
+    }
+    for (RefSpec r : multiMatchRefSpecs()) {
+      if (r.matchSource(branch.branch())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public Collection<String> matchingRefSpecsAsString() {
+    return matchingRefSpecs().stream().map(RefSpec::toString).collect(toImmutableList());
+  }
+
+  public Collection<String> multiMatchRefSpecsAsString() {
+    return multiMatchRefSpecs().stream().map(RefSpec::toString).collect(toImmutableList());
+  }
+
+  /** Evaluates what the destination branches for the subscription are. */
+  public ImmutableSet<BranchNameKey> getDestinationBranches(
+      BranchNameKey src, Collection<Ref> allRefsInRefsHeads) {
+    Set<BranchNameKey> ret = new HashSet<>();
+    logger.atFine().log("Inspecting SubscribeSection %s", this);
+    for (RefSpec r : matchingRefSpecs()) {
+      logger.atFine().log("Inspecting [matching] ref %s", r);
+      if (!r.matchSource(src.branch())) {
+        continue;
+      }
+      if (r.isWildcard()) {
+        // refs/heads/*[:refs/somewhere/*]
+        ret.add(BranchNameKey.create(project(), r.expandFromSource(src.branch()).getDestination()));
+      } else {
+        // e.g. refs/heads/master[:refs/heads/stable]
+        String dest = r.getDestination();
+        if (dest == null) {
+          dest = r.getSource();
+        }
+        ret.add(BranchNameKey.create(project(), dest));
+      }
+    }
+
+    for (RefSpec r : multiMatchRefSpecs()) {
+      logger.atFine().log("Inspecting [all] ref %s", r);
+      if (!r.matchSource(src.branch())) {
+        continue;
+      }
+      for (Ref ref : allRefsInRefsHeads) {
+        if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
+          continue;
+        }
+        BranchNameKey b = BranchNameKey.create(project(), ref.getName());
+        if (!ret.contains(b)) {
+          ret.add(b);
+        }
+      }
+    }
+    logger.atFine().log("Returning possible branches: %s for project %s", ret, project());
+    return ImmutableSet.copyOf(ret);
+  }
+
+  @Override
+  public final String toString() {
+    StringBuilder ret = new StringBuilder();
+    ret.append("[SubscribeSection, project=");
+    ret.append(project());
+    if (!matchingRefSpecs().isEmpty()) {
+      ret.append(", matching=[");
+      for (RefSpec r : matchingRefSpecs()) {
+        ret.append(r.toString());
+        ret.append(", ");
+      }
+    }
+    if (!multiMatchRefSpecs().isEmpty()) {
+      ret.append(", all=[");
+      for (RefSpec r : multiMatchRefSpecs()) {
+        ret.append(r.toString());
+        ret.append(", ");
+      }
+    }
+    ret.append("]");
+    return ret.toString();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/annotations/RemoveAfter.java b/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
index aa31dd0..02f70e9 100644
--- a/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
+++ b/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.extensions.annotations;
 
-import static java.lang.annotation.RetentionPolicy.SOURCE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.ElementType;
@@ -26,7 +26,7 @@
  * period we promised to users.
  */
 @Target({ElementType.PARAMETER, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE})
-@Retention(SOURCE)
+@Retention(RUNTIME)
 @BindingAnnotation
 public @interface RemoveAfter {
   /**
diff --git a/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
deleted file mode 100644
index 39efc64..0000000
--- a/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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
index 5086cd8..da9a8c7 100644
--- a/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
@@ -20,7 +20,7 @@
 /** API for managing the attention set of a change. */
 public interface AttentionSetApi {
 
-  void remove(RemoveFromAttentionSetInput input) throws RestApiException;
+  void remove(AttentionSetInput input) throws RestApiException;
 
   /**
    * A default implementation which allows source compatibility when adding new methods to the
@@ -28,7 +28,7 @@
    */
   class NotImplemented implements AttentionSetApi {
     @Override
-    public void remove(RemoveFromAttentionSetInput input) throws RestApiException {
+    public void remove(AttentionSetInput input) throws RestApiException {
       throw new NotImplementedException();
     }
   }
diff --git a/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
new file mode 100644
index 0000000..4665b11
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.AttentionSetInfo;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
+
+/**
+ * Input at API level to add a user to the attention set.
+ *
+ * @see AttentionSetInfo
+ */
+public class AttentionSetInput {
+  public String user;
+  @DefaultInput public String reason;
+  public NotifyHandling notify;
+  public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  public AttentionSetInput(String user, String reason) {
+    this.user = user;
+    this.reason = reason;
+  }
+
+  public AttentionSetInput(String reason) {
+    this.reason = reason;
+  }
+
+  public AttentionSetInput() {}
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 8df5343..3364fc1 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -302,7 +302,7 @@
   AttentionSetApi attention(String id) throws RestApiException;
 
   /** Adds a user to the attention set. */
-  AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException;
+  AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException;
 
   /** Set the assignee of a change. */
   AccountInfo setAssignee(AssigneeInput input) throws RestApiException;
@@ -326,8 +326,12 @@
    * @return comments in a map keyed by path; comments have the {@code revision} field set to
    *     indicate their patch set.
    * @throws RestApiException
+   * @deprecated Callers should use {@link #commentsRequest()} instead
    */
-  Map<String, List<CommentInfo>> comments() throws RestApiException;
+  @Deprecated
+  default Map<String, List<CommentInfo>> comments() throws RestApiException {
+    return commentsRequest().get();
+  }
 
   /**
    * Get all published comments on a change as a list.
@@ -335,8 +339,20 @@
    * @return comments as a list; comments have the {@code revision} field set to indicate their
    *     patch set.
    * @throws RestApiException
+   * @deprecated Callers should use {@link #commentsRequest()} instead
    */
-  List<CommentInfo> commentsAsList() throws RestApiException;
+  @Deprecated
+  default List<CommentInfo> commentsAsList() throws RestApiException {
+    return commentsRequest().getAsList();
+  }
+
+  /**
+   * Get a {@link CommentsRequest} entity that can be used to retrieve published comments.
+   *
+   * @return A {@link CommentsRequest} entity that can be used to retrieve the comments using the
+   *     {@link CommentsRequest#get()} or {@link CommentsRequest#getAsList()}.
+   */
+  CommentsRequest commentsRequest() throws RestApiException;
 
   /**
    * Get all robot comments on a change.
@@ -395,6 +411,41 @@
    */
   ChangeMessageApi message(String id) throws RestApiException;
 
+  abstract class CommentsRequest {
+    private boolean enableContext;
+
+    /**
+     * Get all published comments on a change.
+     *
+     * @return comments in a map keyed by path; comments have the {@code revision} field set to
+     *     indicate their patch set.
+     * @throws RestApiException
+     */
+    public abstract Map<String, List<CommentInfo>> get() throws RestApiException;
+
+    /**
+     * Get all published comments on a change as a list.
+     *
+     * @return comments as a list; comments have the {@code revision} field set to indicate their
+     *     patch set.
+     */
+    public abstract List<CommentInfo> getAsList() throws RestApiException;
+
+    public CommentsRequest withContext(boolean enableContext) {
+      this.enableContext = enableContext;
+      return this;
+    }
+
+    public CommentsRequest withContext() {
+      this.enableContext = true;
+      return this;
+    }
+
+    public boolean getContext() {
+      return enableContext;
+    }
+  }
+
   abstract class SuggestedReviewersRequest {
     private String query;
     private int limit;
@@ -578,7 +629,7 @@
     }
 
     @Override
-    public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+    public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -603,16 +654,23 @@
     }
 
     @Override
+    @Deprecated
     public Map<String, List<CommentInfo>> comments() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    @Deprecated
     public List<CommentInfo> commentsAsList() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public CommentsRequest commentsRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/CommentInput.java b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
new file mode 100644
index 0000000..5970541
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 to the {@link ChangeApi#comments}. */
+public class CommentInput {
+  public boolean enableContext;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/DraftInput.java b/java/com/google/gerrit/extensions/api/changes/DraftInput.java
index b3c2786..74f626f 100644
--- a/java/com/google/gerrit/extensions/api/changes/DraftInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/DraftInput.java
@@ -19,18 +19,19 @@
 
 public class DraftInput extends Comment {
   public String tag;
+  public Boolean unresolved;
 
   @Override
   public boolean equals(Object o) {
     if (super.equals(o)) {
       DraftInput di = (DraftInput) o;
-      return Objects.equals(tag, di.tag);
+      return Objects.equals(tag, di.tag) && Objects.equals(unresolved, di.unresolved);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(super.hashCode(), tag);
+    return Objects.hash(super.hashCode(), tag, unresolved);
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/FileApi.java b/java/com/google/gerrit/extensions/api/changes/FileApi.java
index 8d9b2d5..26f9452 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileApi.java
@@ -52,7 +52,6 @@
 
   abstract class DiffRequest {
     private String base;
-    private Integer context;
     private Boolean intraline;
     private Whitespace whitespace;
     private OptionalInt parent = OptionalInt.empty();
@@ -64,11 +63,6 @@
       return this;
     }
 
-    public DiffRequest withContext(int context) {
-      this.context = context;
-      return this;
-    }
-
     public DiffRequest withIntraline(boolean intraline) {
       this.intraline = intraline;
       return this;
@@ -88,10 +82,6 @@
       return base;
     }
 
-    public Integer getContext() {
-      return context;
-    }
-
     public Boolean getIntraline() {
       return intraline;
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
deleted file mode 100644
index 9212788..0000000
--- a/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index c4272e4..148d24a 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -17,6 +17,10 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.Map;
 
+/**
+ * Input passed to {@code POST /changes/[change-id]/revert} and {@code POST
+ * /changes/[change-id]/revert_submission}
+ */
 public class RevertInput {
   @DefaultInput public String message;
 
@@ -26,4 +30,10 @@
   public Map<RecipientType, NotifyInfo> notifyDetails;
 
   public String topic;
+
+  /**
+   * Mark the change as work-in-progress. This will also override the {@link #notify} value to
+   * {@link NotifyHandling#OWNER}
+   */
+  public boolean workInProgress;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index b140064..fd445b6 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -21,9 +21,11 @@
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 /** Input passed to {@code POST /changes/[id]/revisions/[id]/review}. */
 public class ReviewInput {
@@ -75,6 +77,20 @@
    */
   public boolean ready;
 
+  /** Users that should be added to the attention set of this change. */
+  public List<AttentionSetInput> addToAttentionSet;
+
+  /** Users that should be removed from the attention set of this change. */
+  public List<AttentionSetInput> removeFromAttentionSet;
+
+  /**
+   * Users in the attention set will only be added and removed based on {@link #addToAttentionSet}
+   * and {@link #removeFromAttentionSet}. Normally, they are also added and removed when some events
+   * occur. E.g, adding/removing reviewers, marking a change ready for review or work in progress,
+   * and replying on changes.
+   */
+  public boolean ignoreAutomaticAttentionSetRules;
+
   public enum DraftHandling {
     /** Leave pending drafts alone. */
     KEEP,
@@ -86,9 +102,11 @@
     PUBLISH_ALL_REVISIONS
   }
 
-  public static class CommentInput extends Comment {}
+  public static class CommentInput extends Comment {
+    public Boolean unresolved;
+  }
 
-  public static class RobotCommentInput extends CommentInput {
+  public static class RobotCommentInput extends Comment {
     public String robotId;
     public String robotRunId;
     public String url;
@@ -101,6 +119,15 @@
     return this;
   }
 
+  public ReviewInput patchSetLevelComment(String message) {
+    Objects.requireNonNull(message);
+    CommentInput comment = new CommentInput();
+    comment.message = message;
+    // TODO(davido): Because of cyclic dependency, we cannot use here Patch.PATCHSET_LEVEL constant
+    comments = Collections.singletonMap("/PATCHSET_LEVEL", Collections.singletonList(comment));
+    return this;
+  }
+
   public ReviewInput label(String name, short value) {
     if (name == null || name.isEmpty()) {
       throw new IllegalArgumentException();
@@ -139,6 +166,33 @@
     return this;
   }
 
+  public ReviewInput addUserToAttentionSet(String user, String reason) {
+    AttentionSetInput input = new AttentionSetInput();
+    input.user = user;
+    input.reason = reason;
+    if (addToAttentionSet == null) {
+      addToAttentionSet = new ArrayList<>();
+    }
+    addToAttentionSet.add(input);
+    return this;
+  }
+
+  public ReviewInput removeUserFromAttentionSet(String user, String reason) {
+    AttentionSetInput input = new AttentionSetInput();
+    input.user = user;
+    input.reason = reason;
+    if (removeFromAttentionSet == null) {
+      removeFromAttentionSet = new ArrayList<>();
+    }
+    removeFromAttentionSet.add(input);
+    return this;
+  }
+
+  public ReviewInput blockAutomaticAttentionSetRules() {
+    ignoreAutomaticAttentionSetRules = true;
+    return this;
+  }
+
   public ReviewInput setWorkInProgress(boolean workInProgress) {
     this.workInProgress = workInProgress;
     ready = !workInProgress;
@@ -170,4 +224,8 @@
   public static ReviewInput reject() {
     return new ReviewInput().label("Code-Review", -2);
   }
+
+  public static ReviewInput create() {
+    return new ReviewInput();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index ff9fb3c..b419c2f 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -106,6 +106,10 @@
 
   List<RobotCommentInfo> robotCommentsAsList() throws RestApiException;
 
+  Map<String, List<CommentInfo>> portedComments() throws RestApiException;
+
+  Map<String, List<CommentInfo>> portedDrafts() throws RestApiException;
+
   /**
    * Applies the indicated fix by creating a new change edit or integrating the fix with the
    * existing change edit. If no change edit exists before this call, the fix must refer to the
@@ -294,6 +298,16 @@
     }
 
     @Override
+    public Map<String, List<CommentInfo>> portedComments() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public Map<String, List<CommentInfo>> portedDrafts() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public EditInfo applyFix(String fixId) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
index 2c166d0..e582f1b 100644
--- a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
@@ -48,6 +48,7 @@
 
   public static class ConsistencyProblemInfo {
     public enum Status {
+      FATAL,
       ERROR,
       WARNING,
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index fb2a0fe..3ba1277 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
@@ -49,7 +51,7 @@
 
   public Map<String, CommentLinkInfo> commentlinks;
 
-  public Map<String, List<String>> extensionPanelNames;
+  public ImmutableMap<String, ImmutableList<String>> extensionPanelNames;
 
   public static class InheritedBooleanInfo {
     public Boolean value;
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index d5fbf89..634992e 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -37,7 +37,12 @@
   public String inReplyTo;
   public Timestamp updated;
   public String message;
-  public Boolean unresolved;
+
+  /**
+   * Hex commit SHA1 (as 40 characters hex string) of the commit of the patchset to which this
+   * comment applies.
+   */
+  public String commitId;
 
   public static class Range implements Comparable<Range> {
     private static final Comparator<Range> RANGE_COMPARATOR =
@@ -122,7 +127,7 @@
           && Objects.equals(inReplyTo, c.inReplyTo)
           && Objects.equals(updated, c.updated)
           && Objects.equals(message, c.message)
-          && Objects.equals(unresolved, c.unresolved);
+          && Objects.equals(commitId, c.commitId);
     }
     return false;
   }
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index ed01a4d..6d52a93 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -28,12 +28,6 @@
   /** Default line length. */
   public static final int DEFAULT_LINE_LENGTH = 100;
 
-  /** Context setting to display the entire file. */
-  public static final short WHOLE_FILE_CONTEXT = -1;
-
-  /** Typical valid choices for the default context setting. */
-  public static final short[] CONTEXT_CHOICES = {3, 10, 25, 50, 75, 100, WHOLE_FILE_CONTEXT};
-
   public enum Whitespace {
     IGNORE_NONE,
     IGNORE_TRAILING,
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 212f6da..30514a6 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -75,6 +75,7 @@
   public enum EmailStrategy {
     ENABLED,
     CC_ON_OWN_COMMENTS,
+    ATTENTION_SET_ONLY,
     DISABLED
   }
 
@@ -102,6 +103,11 @@
     }
   }
 
+  public enum Theme {
+    DARK,
+    LIGHT
+  }
+
   public enum TimeFormat {
     /** 12-hour clock: 1:15 am, 2:13 pm */
     HHMM_12("h:mm a"),
@@ -125,6 +131,7 @@
   /** Type of download URL the user prefers to use. */
   public String downloadScheme;
 
+  public Theme theme;
   public DateFormat dateFormat;
   public TimeFormat timeFormat;
   public Boolean expandInlineDiffs;
@@ -182,6 +189,7 @@
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
     p.downloadScheme = null;
+    p.theme = Theme.LIGHT;
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
     p.expandInlineDiffs = false;
diff --git a/java/com/google/gerrit/extensions/client/ListOption.java b/java/com/google/gerrit/extensions/client/ListOption.java
index 4dea42f..dba2eee 100644
--- a/java/com/google/gerrit/extensions/client/ListOption.java
+++ b/java/com/google/gerrit/extensions/client/ListOption.java
@@ -48,9 +48,9 @@
     return r;
   }
 
-  static String toHex(Set<ListChangesOption> options) {
+  static <T extends Enum<T> & ListOption> String toHex(Set<T> options) {
     int v = 0;
-    for (ListChangesOption option : options) {
+    for (T option : options) {
       v |= 1 << option.getValue();
     }
 
diff --git a/java/com/google/gerrit/extensions/common/AccountInfo.java b/java/com/google/gerrit/extensions/common/AccountInfo.java
index 2a3d260..60ba18d 100644
--- a/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -27,6 +27,12 @@
  * are defined in {@link AccountDetailInfo}.
  */
 public class AccountInfo {
+  /** Tags are additional properties of an account. */
+  public enum Tag {
+    /** Tag indicating that this account is a service user. */
+    SERVICE_USER
+  }
+
   /** The numeric ID of the account. */
   public Integer _accountId;
 
@@ -67,6 +73,9 @@
   /** Whether the account is inactive. */
   public Boolean inactive;
 
+  /** Tags, such as whether this account is a service user. */
+  public List<Tag> tags;
+
   public AccountInfo(Integer id) {
     this._accountId = id;
   }
@@ -89,7 +98,8 @@
           && Objects.equals(username, accountInfo.username)
           && Objects.equals(avatars, accountInfo.avatars)
           && Objects.equals(_moreAccounts, accountInfo._moreAccounts)
-          && Objects.equals(status, accountInfo.status);
+          && Objects.equals(status, accountInfo.status)
+          && Objects.equals(tags, accountInfo.tags);
     }
     return false;
   }
@@ -102,6 +112,7 @@
         .add("displayname", displayName)
         .add("email", email)
         .add("username", username)
+        .add("tags", tags)
         .toString();
   }
 
@@ -116,7 +127,8 @@
         username,
         avatars,
         _moreAccounts,
-        status);
+        status,
+        tags);
   }
 
   protected AccountInfo() {}
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetEntry.java b/java/com/google/gerrit/extensions/common/AttentionSetEntry.java
deleted file mode 100644
index 356b38a..0000000
--- a/java/com/google/gerrit/extensions/common/AttentionSetEntry.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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/AttentionSetInfo.java b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
new file mode 100644
index 0000000..f29d32b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.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 AttentionSetInfo {
+  /** The user included in the attention set. */
+  public AccountInfo account;
+  /** The timestamp of the last update. */
+  public Timestamp lastUpdate;
+  /** The human readable reason why the user was added. */
+  public String reason;
+
+  public AttentionSetInfo(AccountInfo account, Timestamp lastUpdate, String reason) {
+    this.account = account;
+    this.lastUpdate = lastUpdate;
+    this.reason = reason;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index dce6fd1..190a97e 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -42,7 +42,7 @@
    * 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 Map<Integer, AttentionSetInfo> attentionSet;
 
   public AccountInfo assignee;
   public Collection<String> hashtags;
diff --git a/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
index 19e002a..fcce2b3 100644
--- a/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -15,24 +15,34 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.Comment;
+import java.util.List;
 import java.util.Objects;
 
 public class CommentInfo extends Comment {
   public AccountInfo author;
   public String tag;
   public String changeMessageId;
+  public Boolean unresolved;
+
+  /**
+   * A list of {@link ContextLineInfo}, that is, a list of pairs of {line_num, line_text} of the
+   * actual source file content surrounding and including the lines where the comment was written.
+   */
+  public List<ContextLineInfo> contextLines;
 
   @Override
   public boolean equals(Object o) {
     if (super.equals(o)) {
       CommentInfo ci = (CommentInfo) o;
-      return Objects.equals(author, ci.author) && Objects.equals(tag, ci.tag);
+      return Objects.equals(author, ci.author)
+          && Objects.equals(tag, ci.tag)
+          && Objects.equals(unresolved, ci.unresolved);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(super.hashCode(), author, tag);
+    return Objects.hash(super.hashCode(), author, tag, unresolved);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/ContextLineInfo.java b/java/com/google/gerrit/extensions/common/ContextLineInfo.java
new file mode 100644
index 0000000..3062e85
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ContextLineInfo.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.Objects;
+
+/**
+ * An entity class representing 1 line of context {line number, line text} of the source file where
+ * a comment was written.
+ */
+public class ContextLineInfo {
+  public int lineNumber;
+  public String contextLine;
+
+  public ContextLineInfo() {}
+
+  public ContextLineInfo(int lineNumber, String contextLine) {
+    this.lineNumber = lineNumber;
+    this.contextLine = contextLine;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ContextLineInfo) {
+      ContextLineInfo l = (ContextLineInfo) o;
+      return lineNumber == l.lineNumber && contextLine.equals(l.contextLine);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(lineNumber, contextLine);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 53f5e07..734d7e9 100644
--- a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+
 public class MergePatchSetInput {
   public String subject;
   public boolean inheritParent;
   public String baseChange;
   public MergeInput merge;
+  public AccountInput author;
 }
diff --git a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
index e6fef0f..69bfa2c 100644
--- a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -16,4 +16,5 @@
 
 public class PluginDefinedInfo {
   public String name;
+  public String message;
 }
diff --git a/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
index 4170797..deb03b0 100644
--- a/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
+++ b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
@@ -19,7 +19,7 @@
 import java.util.Objects;
 
 public class TestSubmitRuleInfo {
-  /** @see com.google.gerrit.common.data.SubmitRecord.Status */
+  /** @see com.google.gerrit.entities.SubmitRecord.Status */
   public String status;
 
   public String errorMessage;
diff --git a/java/com/google/gerrit/extensions/common/testing/AccountInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/AccountInfoSubject.java
new file mode 100644
index 0000000..8fa6617
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/AccountInfoSubject.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.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.Subject;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** A Truth subject for {@link AccountInfo} instances. */
+public class AccountInfoSubject extends Subject {
+
+  private final AccountInfo accountInfo;
+
+  public static AccountInfoSubject assertThat(AccountInfo accountInfo) {
+    return assertAbout(accounts()).that(accountInfo);
+  }
+
+  public static Factory<AccountInfoSubject, AccountInfo> accounts() {
+    return AccountInfoSubject::new;
+  }
+
+  private AccountInfoSubject(FailureMetadata metadata, AccountInfo accountInfo) {
+    super(metadata, accountInfo);
+    this.accountInfo = accountInfo;
+  }
+
+  public IntegerSubject id() {
+    return check("id").that(accountInfo()._accountId);
+  }
+
+  private AccountInfo accountInfo() {
+    isNotNull();
+    return accountInfo;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java
new file mode 100644
index 0000000..c34e439
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.common.testing.AccountInfoSubject.accounts;
+import static com.google.gerrit.extensions.common.testing.RangeSubject.ranges;
+
+import com.google.common.truth.BooleanSubject;
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.truth.ListSubject;
+import java.sql.Timestamp;
+import java.util.List;
+
+public class CommentInfoSubject extends Subject {
+
+  public static ListSubject<CommentInfoSubject, CommentInfo> assertThatList(
+      List<CommentInfo> commentInfos) {
+    return ListSubject.assertThat(commentInfos, comments());
+  }
+
+  public static CommentInfoSubject assertThat(CommentInfo commentInfo) {
+    return assertAbout(comments()).that(commentInfo);
+  }
+
+  private static Factory<CommentInfoSubject, CommentInfo> comments() {
+    return CommentInfoSubject::new;
+  }
+
+  private final CommentInfo commentInfo;
+
+  private CommentInfoSubject(FailureMetadata failureMetadata, CommentInfo commentInfo) {
+    super(failureMetadata, commentInfo);
+    this.commentInfo = commentInfo;
+  }
+
+  public StringSubject uuid() {
+    return check("id").that(commentInfo().id);
+  }
+
+  public IntegerSubject patchSet() {
+    return check("patchSet").that(commentInfo().patchSet);
+  }
+
+  public StringSubject path() {
+    return check("path").that(commentInfo().path);
+  }
+
+  public IntegerSubject line() {
+    return check("line").that(commentInfo().line);
+  }
+
+  public RangeSubject range() {
+    return check("range").about(ranges()).that(commentInfo().range);
+  }
+
+  public StringSubject message() {
+    return check("message").that(commentInfo().message);
+  }
+
+  public ComparableSubject<Side> side() {
+    return check("side").that(commentInfo().side);
+  }
+
+  public IntegerSubject parent() {
+    return check("parent").that(commentInfo().parent);
+  }
+
+  public BooleanSubject unresolved() {
+    return check("unresolved").that(commentInfo().unresolved);
+  }
+
+  public StringSubject inReplyTo() {
+    return check("inReplyTo").that(commentInfo().inReplyTo);
+  }
+
+  public StringSubject commitId() {
+    return check("commitId").that(commentInfo().commitId);
+  }
+
+  public AccountInfoSubject author() {
+    return check("author").about(accounts()).that(commentInfo().author);
+  }
+
+  public StringSubject tag() {
+    return check("tag").that(commentInfo().tag);
+  }
+
+  public ComparableSubject<Timestamp> updated() {
+    return check("updated").that(commentInfo().updated);
+  }
+
+  public StringSubject changeMessageId() {
+    return check("changeMessageId").that(commentInfo().changeMessageId);
+  }
+
+  private CommentInfo commentInfo() {
+    isNotNull();
+    return commentInfo;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index d6fcb37..d344e18 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -18,11 +18,13 @@
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.gitPersons;
 import static com.google.gerrit.truth.ListSubject.elements;
 
+import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 
 public class CommitInfoSubject extends Subject {
 
@@ -65,4 +67,8 @@
     isNotNull();
     return check("message").that(commitInfo.message);
   }
+
+  public static Correspondence<CommitInfo, String> hasCommit() {
+    return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasCommit");
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
index 0698735..5176145 100644
--- a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.MapSubject;
+import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
@@ -53,6 +55,31 @@
         .thatCustom(robotCommentInfo.fixSuggestions, FixSuggestionInfoSubject.fixSuggestions());
   }
 
+  public StringSubject path() {
+    isNotNull();
+    return check("path").that(robotCommentInfo.path);
+  }
+
+  public StringSubject robotId() {
+    isNotNull();
+    return check("robotId").that(robotCommentInfo.robotId);
+  }
+
+  public StringSubject robotRunId() {
+    isNotNull();
+    return check("robotRunId").that(robotCommentInfo.robotRunId);
+  }
+
+  public StringSubject url() {
+    isNotNull();
+    return check("url").that(robotCommentInfo.url);
+  }
+
+  public MapSubject properties() {
+    isNotNull();
+    return check("property").that(robotCommentInfo.properties);
+  }
+
   public FixSuggestionInfoSubject onlyFixSuggestion() {
     return fixSuggestions().onlyElement();
   }
diff --git a/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java b/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java
new file mode 100644
index 0000000..4033c3e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.restapi.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
+
+/** {@link Subject} for doing assertions on {@link AttentionSetUpdate}s. */
+public class AttentionSetUpdateSubject extends Subject {
+
+  /**
+   * Starts fluent chain to do assertions on a {@link AttentionSetUpdate}.
+   *
+   * @param attentionSetUpdate the {@link AttentionSetUpdate} on which assertions should be done
+   * @return the created {@link AttentionSetUpdateSubject}
+   */
+  public static AttentionSetUpdateSubject assertThat(AttentionSetUpdate attentionSetUpdate) {
+    return assertAbout(attentionSetUpdates()).that(attentionSetUpdate);
+  }
+
+  private static Factory<AttentionSetUpdateSubject, AttentionSetUpdate> attentionSetUpdates() {
+    return AttentionSetUpdateSubject::new;
+  }
+
+  private final AttentionSetUpdate attentionSetUpdate;
+
+  private AttentionSetUpdateSubject(
+      FailureMetadata metadata, AttentionSetUpdate attentionSetUpdate) {
+    super(metadata, attentionSetUpdate);
+    this.attentionSetUpdate = attentionSetUpdate;
+  }
+
+  /**
+   * Returns a {@link ComparableSubject} for the account ID of attention set update.
+   *
+   * @return {@link ComparableSubject} for the account ID of attention set update
+   */
+  public ComparableSubject<Account.Id> hasAccountIdThat() {
+    return check("account()").that(attentionSetUpdate().account());
+  }
+
+  /**
+   * Returns a {@link StringSubject} for the reason of attention set update.
+   *
+   * @return {@link StringSubject} for the reason of attention set update
+   */
+  public StringSubject hasReasonThat() {
+    return check("reason()").that(attentionSetUpdate().reason());
+  }
+
+  /**
+   * Returns a {@link ComparableSubject} for the {@link
+   * com.google.gerrit.entities.AttentionSetUpdate.Operation} of attention set update.
+   *
+   * @return {@link ComparableSubject} for the {@link
+   *     com.google.gerrit.entities.AttentionSetUpdate.Operation} of attention set update.
+   */
+  public ComparableSubject<AttentionSetUpdate.Operation> hasOperationThat() {
+    return check("operation()").that(attentionSetUpdate().operation());
+  }
+
+  private AttentionSetUpdate attentionSetUpdate() {
+    isNotNull();
+    return attentionSetUpdate;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BUILD b/java/com/google/gerrit/extensions/restapi/testing/BUILD
index 4c44d2a..da11ce8 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/BUILD
+++ b/java/com/google/gerrit/extensions/restapi/testing/BUILD
@@ -6,6 +6,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
         "//lib/truth",
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 3a84a29..3ea73c3 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -19,6 +19,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.httpd.WebSessionManager.Key;
@@ -43,7 +44,9 @@
 @RequestScoped
 public abstract class CacheBasedWebSession implements WebSession {
   @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
-  protected static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
+
+  @UsedAt(UsedAt.Project.PLUGIN_WEBSESSION_FLATFILE)
+  public static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
 
   private final HttpServletRequest request;
   private final HttpServletResponse response;
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index b685011..c655b6c 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -172,7 +172,7 @@
         aReq.addExtension(pape);
       }
     } catch (MessageException | ConsumerException e) {
-      logger.atSevere().withCause(e).log("Cannot create OpenID redirect for %s" + openidIdentifier);
+      logger.atSevere().withCause(e).log("Cannot create OpenID redirect for %s", openidIdentifier);
       return new DiscoveryResult(DiscoveryResult.Status.ERROR);
     }
 
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 35c33be..e4a86f5 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -97,6 +97,8 @@
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
+import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener;
 import com.google.gerrit.sshd.SshHostKeyModule;
 import com.google.gerrit.sshd.SshKeyCacheImpl;
 import com.google.gerrit.sshd.SshModule;
@@ -247,7 +249,7 @@
   }
 
   private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+    return new SshAddressesModule().provideListenAddresses(config).isEmpty();
   }
 
   private Injector createCfgInjector() {
@@ -323,6 +325,8 @@
     }
 
     modules.add(new RestApiModule());
+    modules.add(new SubscriptionGraph.Module());
+    modules.add(new SuperprojectUpdateSubmissionListener.Module());
     modules.add(new WorkQueue.Module());
     modules.add(new GerritInstanceNameModule());
     modules.add(
diff --git a/java/com/google/gerrit/httpd/raw/CatServlet.java b/java/com/google/gerrit/httpd/raw/CatServlet.java
index 7a4f4e6..f5d72b2 100644
--- a/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -123,7 +123,7 @@
     final Change.Id changeId = patchKey.patchSetId().changeId();
     String revision;
     try {
-      ChangeNotes notes = changeNotesFactory.createChecked(changeId);
+      ChangeNotes notes = changeNotesFactory.createCheckedUsingIndexLookup(changeId);
       permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
       projectCache
           .get(notes.getProjectName())
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 1680457..46dde41 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd.raw;
 
+import static com.google.gerrit.httpd.raw.IndexPreloadingUtil.RequestedPage;
 import static com.google.template.soy.data.ordainers.GsonOrdainer.serializeObject;
 import static java.util.stream.Collectors.toSet;
 
@@ -21,18 +22,14 @@
 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;
@@ -41,70 +38,29 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+import org.eclipse.jgit.lib.Config;
 
 /** 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 + "(/(.+))" + "/?$");
+  static final ImmutableSet<String> DEFAULT_EXPERIMENTS =
+      ImmutableSet.of(
+          "UiFeature__patchset_comments", "UiFeature__patchset_choice_for_comment_links");
 
   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.
    */
   public static ImmutableMap<String, Object> templateData(
       GerritApi gerritApi,
+      Config gerritServerConfig,
       String canonicalURL,
       String cdnPath,
       String faviconPath,
@@ -115,15 +71,16 @@
     ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
     data.putAll(
             staticTemplateData(
-                canonicalURL,
-                cdnPath,
-                faviconPath,
-                urlParameterMap,
-                urlInScriptTagOrdainer,
-                requestedURL))
-        .putAll(dynamicTemplateData(gerritApi));
+                canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
+        .putAll(dynamicTemplateData(gerritApi, requestedURL));
 
-    Set<String> enabledExperiments = experimentData(urlParameterMap);
+    Set<String> enabledExperiments = new HashSet<>();
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "enabled"))
+        .forEach(enabledExperiments::add);
+    DEFAULT_EXPERIMENTS.forEach(enabledExperiments::add);
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "disabled"))
+        .forEach(enabledExperiments::remove);
+    experimentData(urlParameterMap).forEach(enabledExperiments::add);
     if (!enabledExperiments.isEmpty()) {
       data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
@@ -131,8 +88,8 @@
   }
 
   /** Returns dynamic parameters of {@code index.html}. */
-  public static ImmutableMap<String, Object> dynamicTemplateData(GerritApi gerritApi)
-      throws RestApiException {
+  public static ImmutableMap<String, Object> dynamicTemplateData(
+      GerritApi gerritApi, String requestedURL) throws RestApiException, URISyntaxException {
     ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
     Map<String, SanitizedContent> initialData = new HashMap<>();
     Server serverApi = gerritApi.config().server();
@@ -140,6 +97,28 @@
     initialData.put("\"/config/server/version\"", serializeObject(GSON, serverApi.getVersion()));
     initialData.put("\"/config/server/top-menus\"", serializeObject(GSON, serverApi.topMenus()));
 
+    String requestedPath = IndexPreloadingUtil.getPath(requestedURL);
+    IndexPreloadingUtil.RequestedPage page = IndexPreloadingUtil.parseRequestedPage(requestedPath);
+    switch (page) {
+      case CHANGE:
+        data.put(
+            "defaultChangeDetailHex", IndexPreloadingUtil.getDefaultChangeDetailOptionsAsHex());
+        data.put(
+            "changeRequestsPath",
+            IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
+        break;
+      case DIFF:
+        data.put("defaultDiffDetailHex", IndexPreloadingUtil.getDefaultDiffDetailOptionsAsHex());
+        data.put(
+            "changeRequestsPath",
+            IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
+        break;
+      case DASHBOARD:
+        // Dashboard is preloaded queries are added later when we check user is authenticated.
+      case PAGE_WITHOUT_PRELOADING:
+        break;
+    }
+
     try {
       AccountApi accountApi = gerritApi.accounts().self();
       initialData.put("\"/accounts/self/detail\"", serializeObject(GSON, accountApi.get()));
@@ -152,6 +131,10 @@
           "\"/accounts/self/preferences.edit\"",
           serializeObject(GSON, accountApi.getEditPreferences()));
       data.put("userIsAuthenticated", true);
+      if (page == RequestedPage.DASHBOARD) {
+        data.put("defaultDashboardHex", IndexPreloadingUtil.getDefaultDashboardHex(serverApi));
+        data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList(serverApi));
+      }
     } catch (AuthException e) {
       logger.atFine().log("Can't inline account-related data because user is unauthenticated");
       // Don't render data
@@ -179,8 +162,7 @@
       String cdnPath,
       String faviconPath,
       Map<String, String[]> urlParameterMap,
-      Function<String, SanitizedContent> urlInScriptTagOrdainer,
-      String requestedURL)
+      Function<String, SanitizedContent> urlInScriptTagOrdainer)
       throws URISyntaxException {
     String canonicalPath = computeCanonicalPath(canonicalURL);
 
@@ -203,22 +185,6 @@
     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");
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
new file mode 100644
index 0000000..f1da6b7
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -0,0 +1,223 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+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.config.Server;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ListOption;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.Url;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/** Helper for generating preloading parts of {@code index.html}. */
+@UsedAt(Project.GOOGLE)
+public class IndexPreloadingUtil {
+  enum RequestedPage {
+    CHANGE,
+    DIFF,
+    DASHBOARD,
+    PAGE_WITHOUT_PRELOADING,
+  }
+
+  public static final String CHANGE_CANONICAL_PATH = "/c/(?<project>.+)/\\+/(?<changeNum>\\d+)";
+  public static final String BASE_PATCH_NUM_PATH_PART = "(/(-?\\d+|edit)(\\.\\.(\\d+|edit))?)";
+  public static final Pattern CHANGE_URL_PATTERN =
+      Pattern.compile(CHANGE_CANONICAL_PATH + BASE_PATCH_NUM_PATH_PART + "?" + "/?$");
+  public static final Pattern DIFF_URL_PATTERN =
+      Pattern.compile(CHANGE_CANONICAL_PATH + BASE_PATCH_NUM_PATH_PART + "(/(.+))" + "/?$");
+  public static final Pattern DASHBOARD_PATTERN = Pattern.compile("/dashboard/self$");
+  public static final String ROOT_PATH = "/";
+
+  // These queries should be kept in sync with PolyGerrit:
+  // polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+  public static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft limit:10";
+  public static final String YOUR_TURN = "attention:${user} limit:25";
+  public static final String DASHBOARD_ASSIGNED_QUERY =
+      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open -is:ignored limit:25";
+  public static final String DASHBOARD_WORK_IN_PROGRESS_QUERY =
+      "is:open owner:${user} is:wip limit:25";
+  public static final String DASHBOARD_OUTGOING_QUERY =
+      "is:open owner:${user} -is:wip -is:ignored limit:25";
+  public static final String DASHBOARD_INCOMING_QUERY =
+      "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user}) limit:25";
+  public static final String CC_QUERY = "is:open -is:ignored cc:${user} limit:10";
+  public static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
+      "is:closed -is:ignored (-is:wip OR owner:self) "
+          + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
+          + "OR cc:${user}) -age:4w limit:10";
+  public static final String NEW_USER = "owner:${user} limit:1";
+
+  public static final String SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY =
+      DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY.replaceAll("\\$\\{user}", "self");
+  public static final String SELF_YOUR_TURN = YOUR_TURN.replaceAll("\\$\\{user}", "self");
+  public static final String SELF_DASHBOARD_ASSIGNED_QUERY =
+      DASHBOARD_ASSIGNED_QUERY.replaceAll("\\$\\{user}", "self");
+  public static final ImmutableList<String> SELF_DASHBOARD_QUERIES =
+      Stream.of(
+              DASHBOARD_WORK_IN_PROGRESS_QUERY,
+              DASHBOARD_OUTGOING_QUERY,
+              DASHBOARD_INCOMING_QUERY,
+              CC_QUERY,
+              DASHBOARD_RECENTLY_CLOSED_QUERY,
+              NEW_USER)
+          .map(query -> query.replaceAll("\\$\\{user}", "self"))
+          .collect(toImmutableList());
+
+  public static String getDefaultChangeDetailOptionsAsHex() {
+    Set<ListChangesOption> options =
+        ImmutableSet.of(
+            ListChangesOption.ALL_COMMITS,
+            ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.CHANGE_ACTIONS,
+            ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.DOWNLOAD_COMMANDS,
+            ListChangesOption.MESSAGES,
+            ListChangesOption.SUBMITTABLE,
+            ListChangesOption.WEB_LINKS,
+            ListChangesOption.SKIP_DIFFSTAT);
+
+    return ListOption.toHex(options);
+  }
+
+  public static String getDefaultDiffDetailOptionsAsHex() {
+    Set<ListChangesOption> options =
+        ImmutableSet.of(
+            ListChangesOption.ALL_COMMITS,
+            ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.SKIP_DIFFSTAT);
+
+    return ListOption.toHex(options);
+  }
+
+  public static String getDefaultDashboardHex(Server serverApi) throws RestApiException {
+    Set<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+    options.add(ListChangesOption.LABELS);
+    options.add(ListChangesOption.DETAILED_ACCOUNTS);
+
+    if (!isEnabledAttentionSet(serverApi)) {
+      options.add(ListChangesOption.REVIEWED);
+    }
+    return ListOption.toHex(options);
+  }
+
+  public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
+    if (requestedURL == null) {
+      return null;
+    }
+    URI uri = new URI(requestedURL);
+    return uri.getPath();
+  }
+
+  public static RequestedPage parseRequestedPage(@Nullable String requestedPath) {
+    if (requestedPath == null) {
+      return RequestedPage.PAGE_WITHOUT_PRELOADING;
+    }
+
+    Optional<String> changeRequestsPath =
+        computeChangeRequestsPath(requestedPath, RequestedPage.CHANGE);
+    if (changeRequestsPath.isPresent()) {
+      return RequestedPage.CHANGE;
+    }
+
+    changeRequestsPath = computeChangeRequestsPath(requestedPath, RequestedPage.DIFF);
+    if (changeRequestsPath.isPresent()) {
+      return RequestedPage.DIFF;
+    }
+
+    Matcher dashboardMatcher = IndexPreloadingUtil.DASHBOARD_PATTERN.matcher(requestedPath);
+    if (dashboardMatcher.matches()) {
+      return RequestedPage.DASHBOARD;
+    }
+
+    if (ROOT_PATH.equals(requestedPath)) {
+      return RequestedPage.DASHBOARD;
+    }
+
+    return RequestedPage.PAGE_WITHOUT_PRELOADING;
+  }
+
+  public static Optional<String> computeChangeRequestsPath(
+      String requestedURL, RequestedPage page) {
+    Matcher matcher;
+    switch (page) {
+      case CHANGE:
+        matcher = CHANGE_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DIFF:
+        matcher = DIFF_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DASHBOARD:
+      case PAGE_WITHOUT_PRELOADING:
+      default:
+        return Optional.empty();
+    }
+
+    if (matcher.matches()) {
+      Integer changeId = Ints.tryParse(matcher.group("changeNum"));
+      if (changeId != null) {
+        return Optional.of("changes/" + Url.encode(matcher.group("project")) + "~" + changeId);
+      }
+    }
+    return Optional.empty();
+  }
+
+  public static List<String> computeDashboardQueryList(Server serverApi) throws RestApiException {
+    List<String> queryList = new ArrayList<>();
+    queryList.add(SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY);
+    if (isEnabledAttentionSet(serverApi)) {
+      queryList.add(SELF_YOUR_TURN);
+    }
+    if (isEnabledAssignee(serverApi)) {
+      queryList.add(SELF_DASHBOARD_ASSIGNED_QUERY);
+    }
+
+    queryList.addAll(SELF_DASHBOARD_QUERIES);
+
+    return queryList;
+  }
+
+  private static boolean isEnabledAttentionSet(Server serverApi) throws RestApiException {
+    return serverApi.getInfo() != null
+        && serverApi.getInfo().change != null
+        && serverApi.getInfo().change.enableAttentionSet != null
+        && serverApi.getInfo().change.enableAttentionSet;
+  }
+
+  private static boolean isEnabledAssignee(Server serverApi) throws RestApiException {
+    return serverApi.getInfo() != null
+        && serverApi.getInfo().change != null
+        && serverApi.getInfo().change.enableAssignee != null
+        && serverApi.getInfo().change.enableAssignee;
+  }
+
+  private IndexPreloadingUtil() {}
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index 97d2270..b2bdf7c 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -34,6 +34,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
@@ -42,6 +43,7 @@
   @Nullable private final String cdnPath;
   @Nullable private final String faviconPath;
   private final GerritApi gerritApi;
+  private final Config gerritServerConfig;
   private final SoySauce soySauce;
   private final Function<String, SanitizedContent> urlOrdainer;
 
@@ -49,11 +51,13 @@
       @Nullable String canonicalUrl,
       @Nullable String cdnPath,
       @Nullable String faviconPath,
-      GerritApi gerritApi) {
+      GerritApi gerritApi,
+      Config gerritServerConfig) {
     this.canonicalUrl = canonicalUrl;
     this.cdnPath = cdnPath;
     this.faviconPath = faviconPath;
     this.gerritApi = gerritApi;
+    this.gerritServerConfig = gerritServerConfig;
     this.soySauce =
         SoyFileSet.builder()
             .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
@@ -74,7 +78,14 @@
       // TODO(hiesel): Remove URL ordainer as parameter once Soy is consistent
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
-              gerritApi, canonicalUrl, cdnPath, faviconPath, parameterMap, urlOrdainer, requestUrl);
+              gerritApi,
+              gerritServerConfig,
+              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 07dbf84..13327ca 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -70,6 +70,7 @@
       ImmutableList.of(
           "/",
           "/c/*",
+          "/id/*",
           "/p/*",
           "/q/*",
           "/x/*",
@@ -225,7 +226,7 @@
       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);
+      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, cfg);
     }
 
     @Provides
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index c05516b..1251259 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -56,6 +56,7 @@
  */
 public abstract class QueryProcessor<T> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final int MAX_LIMIT_BUFFER_MULTIPLIER = 100;
 
   protected static class Metrics {
     final Timer1<String> executionTime;
@@ -356,7 +357,7 @@
 
   private int getEffectiveLimit(Predicate<T> p) {
     if (isNoLimit == true) {
-      return Integer.MAX_VALUE;
+      return getIndexSize() + MAX_LIMIT_BUFFER_MULTIPLIER * getBatchSize();
     }
     List<Integer> possibleLimits = new ArrayList<>(4);
     possibleLimits.add(getBackendSupportedLimit());
@@ -384,4 +385,8 @@
   }
 
   protected abstract String formatForLogging(T t);
+
+  protected abstract int getIndexSize();
+
+  protected abstract int getBatchSize();
 }
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
index 439f23f..d9cec45 100644
--- a/java/com/google/gerrit/json/BUILD
+++ b/java/com/google/gerrit/json/BUILD
@@ -6,6 +6,5 @@
     visibility = ["//visibility:public"],
     deps = [
         "//lib:gson",
-        "//lib/flogger:api",
     ],
 )
diff --git a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
index 21c4891..9c32aa8 100644
--- a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
+++ b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.json;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
 import com.google.gson.TypeAdapter;
 import com.google.gson.TypeAdapterFactory;
 import com.google.gson.internal.bind.TypeAdapters;
@@ -32,7 +32,6 @@
  * special behavior: log when input which doesn't match any existing enum value is encountered.
  */
 public class EnumTypeAdapterFactory implements TypeAdapterFactory {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   @SuppressWarnings({"rawtypes", "unchecked"})
   @Override
@@ -65,7 +64,8 @@
       }
       T enumValue = defaultEnumAdapter.read(in);
       if (enumValue == null) {
-        logger.atWarning().log("Expected an existing value for enum %s.", typeToken);
+        throw new JsonSyntaxException(
+            String.format("Expected an existing value for enum %s.", typeToken));
       }
       return enumValue;
     }
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index f6c395e..1999270 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -301,7 +301,7 @@
     move(jars, "javax.inject-1.jar", extapi);
     move(jars, "aopalliance-1.0.jar", extapi);
     move(jars, "guice-servlet-", extapi);
-    move(jars, "tomcat-servlet-api-", extapi);
+    move(jars, "servlet-api-", extapi);
 
     ClassLoader parent = ClassLoader.getSystemClassLoader();
     if (!extapi.isEmpty()) {
diff --git a/java/com/google/gerrit/mail/Address.java b/java/com/google/gerrit/mail/Address.java
deleted file mode 100644
index 520a4c8..0000000
--- a/java/com/google/gerrit/mail/Address.java
+++ /dev/null
@@ -1,135 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.mail;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.common.Nullable;
-
-/** 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('>');
-    final int at = in.indexOf("@");
-    if (0 <= lt && lt < gt && lt + 1 < at && at + 1 < gt) {
-      final String email = in.substring(lt + 1, gt).trim();
-      final String name = in.substring(0, lt).trim();
-      int nameStart = 0;
-      int nameEnd = name.length();
-      if (name.startsWith("\"")) {
-        nameStart++;
-      }
-      if (name.endsWith("\"")) {
-        nameEnd--;
-      }
-      return Address.create(name.length() > 0 ? name.substring(nameStart, nameEnd) : null, email);
-    }
-
-    if (lt < 0 && gt < 0 && 0 < at && at < in.length() - 1) {
-      return Address.create(in);
-    }
-
-    throw new IllegalArgumentException("Invalid email address: " + in);
-  }
-
-  public static Address tryParse(String in) {
-    try {
-      return parse(in);
-    } catch (IllegalArgumentException e) {
-      return null;
-    }
-  }
-
-  public static Address create(String email) {
-    return create(null, email);
-  }
-
-  public static Address create(String name, String email) {
-    return new AutoValue_Address(name, email);
-  }
-
-  @Nullable
-  public abstract String name();
-
-  public abstract String email();
-
-  @Override
-  public final int hashCode() {
-    return email().hashCode();
-  }
-
-  @Override
-  public final boolean equals(Object other) {
-    if (other instanceof Address) {
-      return email().equals(((Address) other).email());
-    }
-    return false;
-  }
-
-  @Override
-  public final String toString() {
-    return toHeaderString();
-  }
-
-  public String toHeaderString() {
-    if (name() != null) {
-      return quotedPhrase(name()) + " <" + email() + ">";
-    } else if (isSimple()) {
-      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);
-      if (c <= ' ' || 0x7F <= c || MUST_QUOTE_EMAIL.indexOf(c) != -1) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  private static String quotedPhrase(String name) {
-    if (EmailHeader.needsQuotedPrintable(name)) {
-      return EmailHeader.quotedPrintable(name);
-    }
-    for (int i = 0; i < name.length(); i++) {
-      final char c = name.charAt(i);
-      if (MUST_QUOTE_NAME.indexOf(c) != -1) {
-        return wrapInQuotes(name);
-      }
-    }
-    return name;
-  }
-
-  private static String wrapInQuotes(String name) {
-    final StringBuilder r = new StringBuilder(2 + name.length());
-    r.append('"');
-    for (int i = 0; i < name.length(); i++) {
-      char c = name.charAt(i);
-      if (c == '"' || c == '\\') {
-        r.append('\\');
-      }
-      r.append(c);
-    }
-    r.append('"');
-    return r.toString();
-  }
-}
diff --git a/java/com/google/gerrit/mail/EmailHeader.java b/java/com/google/gerrit/mail/EmailHeader.java
deleted file mode 100644
index 9b11101..0000000
--- a/java/com/google/gerrit/mail/EmailHeader.java
+++ /dev/null
@@ -1,233 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.mail;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import java.io.IOException;
-import java.io.Writer;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Locale;
-import java.util.Objects;
-
-public abstract class EmailHeader {
-  public abstract boolean isEmpty();
-
-  public abstract void write(Writer w) throws IOException;
-
-  public static class String extends EmailHeader {
-    private final java.lang.String value;
-
-    public String(java.lang.String v) {
-      value = v;
-    }
-
-    public java.lang.String getString() {
-      return value;
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return value == null || value.length() == 0;
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      if (needsQuotedPrintable(value)) {
-        w.write(quotedPrintable(value));
-      } else {
-        w.write(value);
-      }
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(value);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof String) && Objects.equals(value, ((String) o).value);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(value).toString();
-    }
-  }
-
-  public static boolean needsQuotedPrintable(java.lang.String value) {
-    for (int i = 0; i < value.length(); i++) {
-      if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  static boolean needsQuotedPrintableWithinPhrase(int cp) {
-    switch (cp) {
-      case '!':
-      case '*':
-      case '+':
-      case '-':
-      case '/':
-      case '=':
-      case '_':
-        return false;
-      default:
-        if (('a' <= cp && cp <= 'z') || ('A' <= cp && cp <= 'Z') || ('0' <= cp && cp <= '9')) {
-          return false;
-        }
-        return true;
-    }
-  }
-
-  public static java.lang.String quotedPrintable(java.lang.String value) {
-    final StringBuilder r = new StringBuilder();
-
-    r.append("=?UTF-8?Q?");
-    for (int i = 0; i < value.length(); i++) {
-      final int cp = value.codePointAt(i);
-      if (cp == ' ') {
-        r.append('_');
-
-      } else if (needsQuotedPrintableWithinPhrase(cp)) {
-        byte[] buf = new java.lang.String(Character.toChars(cp)).getBytes(UTF_8);
-        for (byte b : buf) {
-          r.append('=');
-          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
-          r.append(Integer.toHexString(b & 0x0f).toUpperCase());
-        }
-
-      } else {
-        r.append(Character.toChars(cp));
-      }
-    }
-    r.append("?=");
-
-    return r.toString();
-  }
-
-  public static class Date extends EmailHeader {
-    private final java.util.Date value;
-
-    public Date(java.util.Date v) {
-      value = v;
-    }
-
-    public java.util.Date getDate() {
-      return value;
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return value == null;
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      final SimpleDateFormat fmt;
-      // Mon, 1 Jun 2009 10:49:44 -0700
-      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
-      w.write(fmt.format(value));
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(value);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof Date) && Objects.equals(value, ((Date) o).value);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(value).toString();
-    }
-  }
-
-  public static class AddressList extends EmailHeader {
-    private final List<Address> list = new ArrayList<>();
-
-    public AddressList() {}
-
-    public AddressList(Address addr) {
-      add(addr);
-    }
-
-    public List<Address> getAddressList() {
-      return Collections.unmodifiableList(list);
-    }
-
-    public void add(Address addr) {
-      list.add(addr);
-    }
-
-    public void remove(java.lang.String email) {
-      list.removeIf(address -> address.email().equals(email));
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return list.isEmpty();
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      int len = 8;
-      boolean firstAddress = true;
-      boolean needComma = false;
-      for (Address addr : list) {
-        java.lang.String s = addr.toHeaderString();
-        if (firstAddress) {
-          firstAddress = false;
-        } else if (72 < len + s.length()) {
-          w.write(",\r\n\t");
-          len = 8;
-          needComma = false;
-        }
-
-        if (needComma) {
-          w.write(", ");
-        }
-        w.write(s);
-        len += s.length();
-        needComma = true;
-      }
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(list);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof AddressList) && Objects.equals(list, ((AddressList) o).list);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(list).toString();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/mail/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
index 7905a0a..ba73bdd 100644
--- a/java/com/google/gerrit/mail/HtmlParser.java
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -35,7 +35,7 @@
           "gmail_quote" // Used for quoting original content
           );
 
-  private static final ImmutableSet<String> WHITELISTED_HTML_TAGS =
+  private static final ImmutableSet<String> ALLOWED_HTML_TAGS =
       ImmutableSet.of(
           "div", // Most user-typed comments are contained in a <div> tag
           "a", // We allow links to be contained in a comment
@@ -60,7 +60,7 @@
    * @return list of MailComments parsed from the html part of the email
    */
   public static List<MailComment> parse(
-      MailMessage email, Collection<Comment> comments, String changeUrl) {
+      MailMessage email, Collection<HumanComment> comments, String changeUrl) {
     // TODO(hiesel) Add support for Gmail Mobile
     // TODO(hiesel) Add tests for other popular email clients
 
@@ -71,10 +71,10 @@
     // Gerrit as these are generally more reliable then the text captions.
     List<MailComment> parsedComments = new ArrayList<>();
     Document d = Jsoup.parse(email.htmlContent());
-    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+    PeekingIterator<HumanComment> iter = Iterators.peekingIterator(comments.iterator());
 
     String lastEncounteredFileName = null;
-    Comment lastEncounteredComment = null;
+    HumanComment lastEncounteredComment = null;
     for (Element e : d.body().getAllElements()) {
       String elementName = e.tagName();
       boolean isInBlockQuote =
@@ -91,7 +91,7 @@
         if (!iter.hasNext()) {
           continue;
         }
-        Comment perspectiveComment = iter.peek();
+        HumanComment perspectiveComment = iter.peek();
         if (href.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
           if (lastEncounteredFileName == null
               || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
@@ -120,8 +120,8 @@
         // There is no user-input in quoted text
         continue;
       }
-      if (!WHITELISTED_HTML_TAGS.contains(elementName)) {
-        // We only accept a set of whitelisted tags that can contain user input
+      if (!ALLOWED_HTML_TAGS.contains(elementName)) {
+        // We only accept a set of allowed tags that can contain user input
         continue;
       }
       if (elementName.equals("a") && e.attr("href").startsWith("mailto:")) {
diff --git a/java/com/google/gerrit/mail/MailComment.java b/java/com/google/gerrit/mail/MailComment.java
index f024f17..3e7da10 100644
--- a/java/com/google/gerrit/mail/MailComment.java
+++ b/java/com/google/gerrit/mail/MailComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.Objects;
 
 /** A comment parsed from inbound email */
@@ -26,7 +26,7 @@
   }
 
   CommentType type;
-  Comment inReplyTo;
+  HumanComment inReplyTo;
   String fileName;
   String message;
   boolean isLink;
@@ -34,7 +34,7 @@
   public MailComment() {}
 
   public MailComment(
-      String message, String fileName, Comment inReplyTo, CommentType type, boolean isLink) {
+      String message, String fileName, HumanComment inReplyTo, CommentType type, boolean isLink) {
     this.message = message;
     this.fileName = fileName;
     this.inReplyTo = inReplyTo;
@@ -46,7 +46,7 @@
     return type;
   }
 
-  public Comment getInReplyTo() {
+  public HumanComment getInReplyTo() {
     return inReplyTo;
   }
 
diff --git a/java/com/google/gerrit/mail/MailHeader.java b/java/com/google/gerrit/mail/MailHeader.java
index 2f31a9c..2700f81 100644
--- a/java/com/google/gerrit/mail/MailHeader.java
+++ b/java/com/google/gerrit/mail/MailHeader.java
@@ -18,6 +18,7 @@
 public enum MailHeader {
   // Gerrit metadata holders
   ASSIGNEE("Gerrit-Assignee"),
+  ATTENTION("Gerrit-Attention"),
   BRANCH("Gerrit-Branch"),
   CC("Gerrit-CC"),
   COMMENT_IN_REPLY_TO("Comment-In-Reply-To"),
diff --git a/java/com/google/gerrit/mail/MailMessage.java b/java/com/google/gerrit/mail/MailMessage.java
index bb83dfd..2ce6cbb 100644
--- a/java/com/google/gerrit/mail/MailMessage.java
+++ b/java/com/google/gerrit/mail/MailMessage.java
@@ -17,6 +17,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
 import java.time.Instant;
 
 /**
diff --git a/java/com/google/gerrit/mail/ParserUtil.java b/java/com/google/gerrit/mail/ParserUtil.java
index 4b292f3..40c5a95 100644
--- a/java/com/google/gerrit/mail/ParserUtil.java
+++ b/java/com/google/gerrit/mail/ParserUtil.java
@@ -115,7 +115,8 @@
     int numConsecutiveDigits = 0;
     int maxConsecutiveDigits = 0;
     int numDigitGroups = 0;
-    for (char c : s.toCharArray()) {
+    for (int i = 0; i < s.length(); i++) {
+      char c = s.charAt(i);
       if (c >= '0' && c <= '9') {
         numConsecutiveDigits++;
       } else if (numConsecutiveDigits > 0) {
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
index 4e005a5..213cc3f 100644
--- a/java/com/google/gerrit/mail/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.io.CharStreams;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Address;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
diff --git a/java/com/google/gerrit/mail/TextParser.java b/java/com/google/gerrit/mail/TextParser.java
index dac3deb..a33c66f 100644
--- a/java/com/google/gerrit/mail/TextParser.java
+++ b/java/com/google/gerrit/mail/TextParser.java
@@ -18,7 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -31,15 +31,15 @@
    * Parses comments from plaintext email.
    *
    * @param email @param email the message as received from the email service
-   * @param comments list of {@link Comment}s previously persisted on the change that caused the
-   *     original notification email to be sent out. Ordering must be the same as in the outbound
-   *     email
+   * @param comments list of {@link HumanComment}s previously persisted on the change that caused
+   *     the original notification email to be sent out. Ordering must be the same as in the
+   *     outbound email
    * @param changeUrl canonical change url that points to the change on this Gerrit instance.
    *     Example: https://go-review.googlesource.com/#/c/91570
    * @return list of MailComments parsed from the plaintext part of the email
    */
   public static List<MailComment> parse(
-      MailMessage email, Collection<Comment> comments, String changeUrl) {
+      MailMessage email, Collection<HumanComment> comments, String changeUrl) {
     String body = email.textContent();
     // Replace CR-LF by \n
     body = body.replace("\r\n", "\n");
@@ -62,11 +62,11 @@
       body = body.replace(doubleQuotePattern, singleQuotePattern);
     }
 
-    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+    PeekingIterator<HumanComment> iter = Iterators.peekingIterator(comments.iterator());
 
     MailComment currentComment = null;
     String lastEncounteredFileName = null;
-    Comment lastEncounteredComment = null;
+    HumanComment lastEncounteredComment = null;
     for (String line : Splitter.on('\n').split(body)) {
       if (line.equals(">")) {
         // Skip empty lines
@@ -89,7 +89,7 @@
         if (!iter.hasNext()) {
           continue;
         }
-        Comment perspectiveComment = iter.peek();
+        HumanComment perspectiveComment = iter.peek();
         if (line.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
           if (lastEncounteredFileName == null
               || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 449a868..5f675bcc 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -108,6 +108,8 @@
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
+import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener;
 import com.google.gerrit.sshd.SshHostKeyModule;
 import com.google.gerrit.sshd.SshKeyCacheImpl;
 import com.google.gerrit.sshd.SshModule;
@@ -390,7 +392,7 @@
   }
 
   private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+    return new SshAddressesModule().provideListenAddresses(config).isEmpty();
   }
 
   private String myVersion() {
@@ -421,6 +423,8 @@
     // work queue can get stuck waiting on index futures that will never return.
     modules.add(createIndexModule());
 
+    modules.add(new SubscriptionGraph.Module());
+    modules.add(new SuperprojectUpdateSubmissionListener.Module());
     modules.add(new WorkQueue.Module());
     modules.add(new StreamEventsApiListener.Module());
     modules.add(new EventBroker.Module());
@@ -501,12 +505,13 @@
       modules.add(new AccountDeactivator.Module());
       modules.add(new ChangeCleanupRunner.Module());
     }
-    modules.addAll(testSysModules);
     modules.add(new LocalMergeSuperSetComputation.Module());
     modules.add(new DefaultProjectNameLockManager.Module());
-    return cfgInjector.createChildInjector(
-        ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE)));
+
+    List<Module> libModules = LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE);
+    libModules.addAll(testSysModules);
+
+    return cfgInjector.createChildInjector(ModuleOverloader.override(modules, libModules));
   }
 
   private Module createIndexModule() {
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 993e00f..03f66eb 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -193,7 +193,7 @@
                 .toInstance(IsFirstInsertForEntry.YES);
           }
         });
-    modules.add(new BatchProgramModule());
+    modules.add(new BatchProgramModule(dbInjector));
     modules.add(
         new FactoryModule() {
           @Override
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 0333942..ca28255 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -19,9 +19,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index cf208ae..effb4c6 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -17,8 +17,8 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
diff --git a/java/com/google/gerrit/pgm/init/InitJGitConfig.java b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
new file mode 100644
index 0000000..6e37f7f
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.init;
+
+import static com.google.gerrit.pgm.init.api.InitUtil.die;
+
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.server.config.SitePaths;
+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.ConfigConstants;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.TransferConfig;
+import org.eclipse.jgit.util.FS;
+
+/** Initialize the JGit configuration. */
+@Singleton
+class InitJGitConfig implements InitStep {
+  private final ConsoleUI ui;
+  private final SitePaths sitePaths;
+
+  @Inject
+  InitJGitConfig(ConsoleUI ui, SitePaths sitePaths) {
+    this.ui = ui;
+    this.sitePaths = sitePaths;
+  }
+
+  @Override
+  public void run() {
+    ui.header("JGit Configuration");
+    FileBasedConfig jgitConfig = new FileBasedConfig(sitePaths.jgit_config.toFile(), FS.DETECTED);
+    try {
+      jgitConfig.load();
+      if (!jgitConfig
+          .getNames(ConfigConstants.CONFIG_RECEIVE_SECTION)
+          .contains(ConfigConstants.CONFIG_KEY_AUTOGC)) {
+        jgitConfig.setBoolean(
+            ConfigConstants.CONFIG_RECEIVE_SECTION, null, ConfigConstants.CONFIG_KEY_AUTOGC, false);
+        jgitConfig.save();
+        ui.error(
+            "Auto-configured \"receive.autogc = false\" to disable auto-gc after git-receive-pack.");
+      } else if (jgitConfig.getBoolean(
+          ConfigConstants.CONFIG_RECEIVE_SECTION, ConfigConstants.CONFIG_KEY_AUTOGC, true)) {
+        ui.error(
+            "WARNING: JGit option \"receive.autogc = true\". This is not recommended in Gerrit.\n"
+                + "git-receive-pack will run auto gc after receiving data from "
+                + "git-push and updating refs.\n"
+                + "Disable this behavior to avoid the additional load it creates: "
+                + "gc should be configured in gc config section or run as a separate process.");
+      }
+
+      if (!jgitConfig
+          .getNames(ConfigConstants.CONFIG_PROTOCOL_SECTION)
+          .contains(ConfigConstants.CONFIG_KEY_VERSION)) {
+        jgitConfig.setString(
+            ConfigConstants.CONFIG_PROTOCOL_SECTION,
+            null,
+            ConfigConstants.CONFIG_KEY_VERSION,
+            TransferConfig.ProtocolVersion.V2.version());
+        jgitConfig.save();
+        ui.error(
+            String.format(
+                "Auto-configured \"%s.%s = %s\" to activate git wire protocol version 2.",
+                ConfigConstants.CONFIG_PROTOCOL_SECTION,
+                ConfigConstants.CONFIG_KEY_VERSION,
+                TransferConfig.ProtocolVersion.V2.version()));
+      } else {
+        String version =
+            jgitConfig.getString(
+                ConfigConstants.CONFIG_PROTOCOL_SECTION, null, ConfigConstants.CONFIG_KEY_VERSION);
+        if (!TransferConfig.ProtocolVersion.V2.version().equals(version)) {
+          ui.error(
+              String.format(
+                  "HINT: JGit option \"%s.%s = %s\". It's recommended to activate git\n"
+                      + "wire protocol version 2 to improve git fetch performance.",
+                  ConfigConstants.CONFIG_PROTOCOL_SECTION,
+                  ConfigConstants.CONFIG_KEY_VERSION,
+                  version));
+        }
+      }
+    } catch (IOException e) {
+      throw die(String.format("Handling JGit configuration %s failed", sitePaths.jgit_config), e);
+    } catch (ConfigInvalidException e) {
+      throw die(String.format("Invalid JGit configuration %s", sitePaths.jgit_config), e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
index 0797cf9..3edc732 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.pgm.init;
 
-import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.MAX_WITH_BLOCK;
 
 import com.google.gerrit.pgm.init.api.AllProjectsConfig;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
diff --git a/java/com/google/gerrit/pgm/init/InitModule.java b/java/com/google/gerrit/pgm/init/InitModule.java
index b658675..32c6697 100644
--- a/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/java/com/google/gerrit/pgm/init/InitModule.java
@@ -40,6 +40,7 @@
     // Steps are executed in the order listed here.
     //
     step().to(InitGitManager.class);
+    step().to(InitJGitConfig.class);
     step().to(InitLogging.class);
     step().to(InitIndex.class);
     step().to(InitAuth.class);
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 846bb82..ddc4f79 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -108,6 +108,8 @@
     extractMailExample("AbandonedHtml.soy");
     extractMailExample("AddKey.soy");
     extractMailExample("AddKeyHtml.soy");
+    extractMailExample("AddToAttentionSet.soy");
+    extractMailExample("AddToAttentionSetHtml.soy");
     extractMailExample("ChangeFooter.soy");
     extractMailExample("ChangeFooterHtml.soy");
     extractMailExample("ChangeSubject.soy");
@@ -123,7 +125,8 @@
     extractMailExample("DeleteVoteHtml.soy");
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
-    extractMailExample("HeaderHtml.soy");
+    extractMailExample("ChangeHeader.soy");
+    extractMailExample("ChangeHeaderHtml.soy");
     extractMailExample("HttpPasswordUpdate.soy");
     extractMailExample("HttpPasswordUpdateHtml.soy");
     extractMailExample("InboundEmailRejection.soy");
@@ -133,6 +136,9 @@
     extractMailExample("NewChange.soy");
     extractMailExample("NewChangeHtml.soy");
     extractMailExample("RegisterNewEmail.soy");
+    extractMailExample("RegisterNewEmailHtml.soy");
+    extractMailExample("RemoveFromAttentionSet.soy");
+    extractMailExample("RemoveFromAttentionSetHtml.soy");
     extractMailExample("ReplacePatchSet.soy");
     extractMailExample("ReplacePatchSetHtml.soy");
     extractMailExample("Restored.soy");
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index dd76859..af01343 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -18,8 +18,8 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -28,6 +28,9 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.LibModuleType;
+import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountVisibilityProvider;
 import com.google.gerrit.server.account.CapabilityCollection;
@@ -35,6 +38,7 @@
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
@@ -83,22 +87,33 @@
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.inject.Injector;
+import com.google.inject.Module;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 
 /** Module for programs that perform batch operations on a site. */
 public class BatchProgramModule extends FactoryModule {
+  private Injector parentInjector;
+
+  public BatchProgramModule(Injector parentInjector) {
+    this.parentInjector = parentInjector;
+  }
+
   @SuppressWarnings("rawtypes")
   @Override
   protected void configure() {
-    install(new DiffExecutorModule());
-    install(new SysExecutorModule());
-    install(BatchUpdate.module());
-    install(PatchListCacheImpl.module());
-    install(new DefaultUrlFormatter.Module());
+    List<Module> modules = new ArrayList<>();
+
+    modules.add(new DiffExecutorModule());
+    modules.add(new SysExecutorModule());
+    modules.add(BatchUpdate.module());
+    modules.add(PatchListCacheImpl.module());
+    modules.add(new DefaultUrlFormatter.Module());
 
     // There is the concept of LifecycleModule, in Gerrit's own extension to Guice, which has these:
     //  listener().to(SomeClassImplementingLifecycleListener.class);
@@ -107,7 +122,7 @@
     // plugins get loaded and the respective Guice modules installed so that the on-line reindexing
     // will happen with the proper classes (e.g. group backends, custom Prolog predicates) and the
     // associated rules ready to be evaluated.
-    install(new PluginModule());
+    modules.add(new PluginModule());
 
     // We're just running through each change
     // once, so don't worry about cache removal.
@@ -149,23 +164,24 @@
         .annotatedWith(GitReceivePackGroups.class)
         .toInstance(Collections.emptySet());
 
-    install(new BatchGitModule());
-    install(new DefaultPermissionBackendModule());
-    install(new DefaultMemoryCacheModule());
-    install(new H2CacheModule());
-    install(new ExternalIdModule());
-    install(new GroupModule());
-    install(new NoteDbModule());
-    install(AccountCacheImpl.module());
-    install(DefaultPreferencesCacheImpl.module());
-    install(GroupCacheImpl.module());
-    install(GroupIncludeCacheImpl.module());
-    install(ProjectCacheImpl.module());
-    install(SectionSortCache.module());
-    install(ChangeKindCacheImpl.module());
-    install(MergeabilityCacheImpl.module());
-    install(TagCache.module());
-    install(PureRevertCache.module());
+    modules.add(new BatchGitModule());
+    modules.add(new DefaultPermissionBackendModule());
+    modules.add(new DefaultMemoryCacheModule());
+    modules.add(new H2CacheModule());
+    modules.add(new ExternalIdModule());
+    modules.add(new GroupModule());
+    modules.add(new NoteDbModule());
+    modules.add(AccountCacheImpl.module());
+    modules.add(DefaultPreferencesCacheImpl.module());
+    modules.add(GroupCacheImpl.module());
+    modules.add(GroupIncludeCacheImpl.module());
+    modules.add(ProjectCacheImpl.module());
+    modules.add(SectionSortCache.module());
+    modules.add(ChangeKindCacheImpl.module());
+    modules.add(MergeabilityCacheImpl.module());
+    modules.add(ServiceUserClassifierImpl.module());
+    modules.add(TagCache.module());
+    modules.add(PureRevertCache.module());
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
@@ -174,9 +190,9 @@
     // Submit rules
     DynamicSet.setOf(binder(), SubmitRule.class);
     factory(SubmitRuleEvaluator.Factory.class);
-    install(new PrologModule());
-    install(new DefaultSubmitRule.Module());
-    install(new IgnoreSelfApprovalRule.Module());
+    modules.add(new PrologModule());
+    modules.add(new DefaultSubmitRule.Module());
+    modules.add(new IgnoreSelfApprovalRule.Module());
 
     bind(ChangeJson.Factory.class).toProvider(Providers.of(null));
     bind(EventUtil.class).toProvider(Providers.of(null));
@@ -184,5 +200,10 @@
     bind(RevisionCreated.class).toInstance(RevisionCreated.DISABLED);
     bind(WorkInProgressStateChanged.class).toInstance(WorkInProgressStateChanged.DISABLED);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
+
+    ModuleOverloader.override(
+            modules, LibModuleLoader.loadModules(parentInjector, LibModuleType.SYS_BATCH_MODULE))
+        .stream()
+        .forEach(this::install);
   }
 }
diff --git a/java/com/google/gerrit/prettify/common/EditHunk.java b/java/com/google/gerrit/prettify/common/EditHunk.java
new file mode 100644
index 0000000..68cb796
--- /dev/null
+++ b/java/com/google/gerrit/prettify/common/EditHunk.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 java.util.List;
+import org.eclipse.jgit.diff.Edit;
+
+/**
+ * This is a legacy class. It was only simplified but not improved regarding readability or code
+ * health. Feel free to completely rewrite it or replace it with some other, better code.
+ */
+public class EditHunk {
+  private final List<Edit> edits;
+
+  private int curIdx;
+  private Edit curEdit;
+
+  private int aCur;
+  private int bCur;
+  private final int aEnd;
+  private final int bEnd;
+
+  public EditHunk(List<Edit> edits, int aSize, int bSize) {
+    this.edits = edits;
+
+    curIdx = 0;
+    curEdit = edits.get(curIdx);
+
+    aCur = 0;
+    bCur = 0;
+    aEnd = aSize;
+    bEnd = bSize;
+  }
+
+  public int getCurA() {
+    return aCur;
+  }
+
+  public int getCurB() {
+    return bCur;
+  }
+
+  public void incA() {
+    aCur++;
+  }
+
+  public void incB() {
+    bCur++;
+  }
+
+  public void incBoth() {
+    incA();
+    incB();
+  }
+
+  public boolean isUnmodifiedLine() {
+    return !isDeletedA() && !isInsertedB();
+  }
+
+  public boolean isDeletedA() {
+    return curEdit.getBeginA() <= aCur && aCur < curEdit.getEndA();
+  }
+
+  public boolean isInsertedB() {
+    return curEdit.getBeginB() <= bCur && bCur < curEdit.getEndB();
+  }
+
+  public boolean next() {
+    if (!in(curEdit)) {
+      if (curIdx < edits.size() - 1) {
+        curEdit = edits.get(++curIdx);
+      }
+    }
+    return aCur < aEnd || bCur < bEnd;
+  }
+
+  private boolean in(Edit edit) {
+    return aCur < edit.getEndA() || bCur < edit.getEndB();
+  }
+}
diff --git a/java/com/google/gerrit/prettify/common/EditList.java b/java/com/google/gerrit/prettify/common/EditList.java
deleted file mode 100644
index 172a346..0000000
--- a/java/com/google/gerrit/prettify/common/EditList.java
+++ /dev/null
@@ -1,174 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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 java.util.Iterator;
-import java.util.List;
-import org.eclipse.jgit.diff.Edit;
-
-public class EditList {
-  private final List<Edit> edits;
-  private final int context;
-  private final int aSize;
-  private final int bSize;
-
-  public EditList(final List<Edit> edits, int contextLines, int aSize, int bSize) {
-    this.edits = edits;
-    this.context = contextLines;
-    this.aSize = aSize;
-    this.bSize = bSize;
-  }
-
-  public List<Edit> getEdits() {
-    return edits;
-  }
-
-  public Iterable<Hunk> getHunks() {
-    return () ->
-        new Iterator<Hunk>() {
-          private int curIdx;
-
-          @Override
-          public boolean hasNext() {
-            return curIdx < edits.size();
-          }
-
-          @Override
-          public Hunk next() {
-            final int c = curIdx;
-            final int e = findCombinedEnd(c);
-            curIdx = e + 1;
-            return new Hunk(c, e);
-          }
-
-          @Override
-          public void remove() {
-            throw new UnsupportedOperationException();
-          }
-        };
-  }
-
-  private int findCombinedEnd(int i) {
-    int end = i + 1;
-    while (end < edits.size() && (combineA(end) || combineB(end))) {
-      end++;
-    }
-    return end - 1;
-  }
-
-  private boolean combineA(int i) {
-    final Edit s = edits.get(i);
-    final Edit e = edits.get(i - 1);
-    // + 1 to prevent '... skipping 1 common line ...' messages.
-    return s.getBeginA() - e.getEndA() <= 2 * context + 1;
-  }
-
-  private boolean combineB(int i) {
-    final int s = edits.get(i).getBeginB();
-    final int e = edits.get(i - 1).getEndB();
-    // + 1 to prevent '... skipping 1 common line ...' messages.
-    return s - e <= 2 * context + 1;
-  }
-
-  public class Hunk {
-    private int curIdx;
-    private Edit curEdit;
-    private final int endIdx;
-    private final Edit endEdit;
-
-    private int aCur;
-    private int bCur;
-    private final int aEnd;
-    private final int bEnd;
-
-    private Hunk(int ci, int ei) {
-      curIdx = ci;
-      endIdx = ei;
-      curEdit = edits.get(curIdx);
-      endEdit = edits.get(endIdx);
-
-      aCur = Math.max(0, curEdit.getBeginA() - context);
-      bCur = Math.max(0, curEdit.getBeginB() - context);
-      aEnd = Math.min(aSize, endEdit.getEndA() + context);
-      bEnd = Math.min(bSize, endEdit.getEndB() + context);
-    }
-
-    public int getCurA() {
-      return aCur;
-    }
-
-    public int getCurB() {
-      return bCur;
-    }
-
-    public Edit getCurEdit() {
-      return curEdit;
-    }
-
-    public int getEndA() {
-      return aEnd;
-    }
-
-    public int getEndB() {
-      return bEnd;
-    }
-
-    public void incA() {
-      aCur++;
-    }
-
-    public void incB() {
-      bCur++;
-    }
-
-    public void incBoth() {
-      incA();
-      incB();
-    }
-
-    public boolean isStartOfFile() {
-      return aCur == 0 && bCur == 0;
-    }
-
-    public boolean isContextLine() {
-      return !isModifiedLine();
-    }
-
-    public boolean isDeletedA() {
-      return curEdit.getBeginA() <= aCur && aCur < curEdit.getEndA();
-    }
-
-    public boolean isInsertedB() {
-      return curEdit.getBeginB() <= bCur && bCur < curEdit.getEndB();
-    }
-
-    public boolean isModifiedLine() {
-      return isDeletedA() || isInsertedB();
-    }
-
-    public boolean next() {
-      if (!in(curEdit)) {
-        if (curIdx < endIdx) {
-          curEdit = edits.get(++curIdx);
-        }
-      }
-      return aCur < aEnd || bCur < bEnd;
-    }
-
-    private boolean in(Edit edit) {
-      return aCur < edit.getEndA() || bCur < edit.getEndB();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index 417a4ef..aa3ef89 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -23,8 +23,8 @@
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ChangeKind;
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 0280aee..411768d 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -27,11 +27,11 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -117,16 +117,6 @@
   }
 
   /**
-   * Get all reviewers and CCed accounts for a change.
-   *
-   * @param allApprovals all approvals to consider; must all belong to the same change.
-   * @return reviewers for the change.
-   */
-  public ReviewerSet getReviewers(ChangeNotes notes, Iterable<PatchSetApproval> allApprovals) {
-    return notes.load().getReviewers();
-  }
-
-  /**
    * Get updates to reviewer set.
    *
    * @param notes change notes.
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 1c46ed6..069006b 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -54,6 +54,8 @@
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/cache/serialize/entities",
+        "//java/com/google/gerrit/server/data",
         "//java/com/google/gerrit/server/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/server/CacheRefreshExecutor.java b/java/com/google/gerrit/server/CacheRefreshExecutor.java
new file mode 100644
index 0000000..1a377c3
--- /dev/null
+++ b/java/com/google/gerrit/server/CacheRefreshExecutor.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on the global {@link java.util.concurrent.ThreadPoolExecutor} used to refresh outdated
+ * values in caches.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface CacheRefreshExecutor {}
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index dd48b93..32edadb 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -35,34 +35,38 @@
 @Singleton
 public class ChangeMessagesUtil {
   public static final String AUTOGENERATED_TAG_PREFIX = "autogenerated:";
+  public static final String AUTOGENERATED_BY_GERRIT_TAG_PREFIX =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:";
 
-  public static final String TAG_ABANDON = AUTOGENERATED_TAG_PREFIX + "gerrit:abandon";
+  public static final String TAG_ABANDON = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "abandon";
   public static final String TAG_CHERRY_PICK_CHANGE =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:cherryPickChange";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "cherryPickChange";
   public static final String TAG_DELETE_ASSIGNEE =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:deleteAssignee";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteAssignee";
   public static final String TAG_DELETE_REVIEWER =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:deleteReviewer";
-  public static final String TAG_DELETE_VOTE = AUTOGENERATED_TAG_PREFIX + "gerrit:deleteVote";
-  public static final String TAG_MERGED = AUTOGENERATED_TAG_PREFIX + "gerrit:merged";
-  public static final String TAG_MOVE = AUTOGENERATED_TAG_PREFIX + "gerrit:move";
-  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";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteReviewer";
+  public static final String TAG_DELETE_VOTE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteVote";
+  public static final String TAG_MERGED = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "merged";
+  public static final String TAG_MOVE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "move";
+  public static final String TAG_RESTORE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "restore";
+  public static final String TAG_REVERT = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "revert";
+  public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setAssignee";
   public static final String TAG_UPDATE_ATTENTION_SET =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:updateAttentionSet";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "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";
-  public static final String TAG_SET_PRIVATE = AUTOGENERATED_TAG_PREFIX + "gerrit:setPrivate";
-  public static final String TAG_SET_READY = AUTOGENERATED_TAG_PREFIX + "gerrit:setReadyForReview";
-  public static final String TAG_SET_TOPIC = AUTOGENERATED_TAG_PREFIX + "gerrit:setTopic";
-  public static final String TAG_SET_WIP = AUTOGENERATED_TAG_PREFIX + "gerrit:setWorkInProgress";
-  public static final String TAG_UNSET_PRIVATE = AUTOGENERATED_TAG_PREFIX + "gerrit:unsetPrivate";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setPsDescription";
+  public static final String TAG_SET_HASHTAGS = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setHashtag";
+  public static final String TAG_SET_PRIVATE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setPrivate";
+  public static final String TAG_SET_READY =
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setReadyForReview";
+  public static final String TAG_SET_TOPIC = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setTopic";
+  public static final String TAG_SET_WIP = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setWorkInProgress";
+  public static final String TAG_UNSET_PRIVATE =
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "unsetPrivate";
   public static final String TAG_UPLOADED_PATCH_SET =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:newPatchSet";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "newPatchSet";
   public static final String TAG_UPLOADED_WIP_PATCH_SET =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:newWipPatchSet";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "newWipPatchSet";
 
   public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
     return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
@@ -122,6 +126,10 @@
     return tag != null && tag.startsWith(AUTOGENERATED_TAG_PREFIX);
   }
 
+  public static boolean isAutogeneratedByGerrit(@Nullable String tag) {
+    return tag != null && tag.startsWith(AUTOGENERATED_BY_GERRIT_TAG_PREFIX);
+  }
+
   public static ChangeMessageInfo createChangeMessageInfo(
       ChangeMessage message, AccountLoader accountLoader) {
     PatchSet.Id patchNum = message.getPatchSetId();
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index 673695b6..46e8d33 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -24,14 +24,18 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.security.SecureRandom;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import java.util.Random;
 import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -145,5 +149,29 @@
     return c != null ? c.getStatus().name().toLowerCase() : "deleted";
   }
 
+  private static final Pattern LINK_CHANGE_ID_PATTERN = Pattern.compile("I[0-9a-f]{40}");
+
+  public static List<String> getChangeIdsFromFooter(RevCommit c, UrlFormatter urlFormatter) {
+    List<String> changeIds = c.getFooterLines(FooterConstants.CHANGE_ID);
+    Optional<String> webUrl = urlFormatter.getWebUrl();
+    if (!webUrl.isPresent()) {
+      return changeIds;
+    }
+
+    String prefix = webUrl.get() + "id/";
+    for (String link : c.getFooterLines(FooterConstants.LINK)) {
+      if (!link.startsWith(prefix)) {
+        continue;
+      }
+      String changeId = link.substring(prefix.length());
+      Matcher m = LINK_CHANGE_ID_PATTERN.matcher(changeId);
+      if (m.matches()) {
+        changeIds.add(changeId);
+      }
+    }
+
+    return changeIds;
+  }
+
   private ChangeUtil() {}
 }
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
new file mode 100644
index 0000000..bbc7cf3
--- /dev/null
+++ b/java/com/google/gerrit/server/CommentContextLoader.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static java.util.stream.Collectors.groupingBy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.Text;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+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 org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
+ * source file surrounding and including the area where the comment was written.
+ */
+public class CommentContextLoader {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager repoManager;
+  private final Project.NameKey project;
+  private Map<ContextData, List<ContextLineInfo>> candidates;
+
+  public interface Factory {
+    CommentContextLoader create(Project.NameKey project);
+  }
+
+  @Inject
+  CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
+    this.repoManager = repoManager;
+    this.project = project;
+    this.candidates = new HashMap<>();
+  }
+
+  /**
+   * Returns an empty list of {@link ContextLineInfo}. Clients are expected to call this method one
+   * or more times. Each call returns a reference to an empty {@link List
+   * List&lt;ContextLineInfo&gt;}.
+   *
+   * <p>A single call to {@link #fill()} will cause all list references returned from this method to
+   * be populated. If a client calls this method again with a comment that was passed before calling
+   * {@link #fill()}, the new populated list will be returned.
+   *
+   * @param comment the comment entity for which we want to load the context
+   * @return a list of {@link ContextLineInfo}
+   */
+  public List<ContextLineInfo> getContext(CommentInfo comment) {
+    ContextData key =
+        ContextData.create(
+            comment.id,
+            ObjectId.fromString(comment.commitId),
+            comment.path,
+            getStartAndEndLines(comment));
+    List<ContextLineInfo> context = candidates.get(key);
+    if (context == null) {
+      context = new ArrayList<>();
+      candidates.put(key, context);
+    }
+    return context;
+  }
+
+  /**
+   * A call to this method loads the context for all comments stored in {@link
+   * CommentContextLoader#candidates}. This is useful so that the repository is opened once for all
+   * comments.
+   */
+  public void fill() {
+    // Group comments by commit ID so that each commit is parsed only once
+    Map<ObjectId, List<ContextData>> commentsByCommitId =
+        candidates.keySet().stream().collect(groupingBy(ContextData::commitId));
+
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      for (ObjectId commitId : commentsByCommitId.keySet()) {
+        RevCommit commit = rw.parseCommit(commitId);
+        for (ContextData k : commentsByCommitId.get(commitId)) {
+          if (!k.range().isPresent()) {
+            continue;
+          }
+          try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), k.path(), commit.getTree())) {
+            if (tw == null) {
+              logger.atWarning().log(
+                  "Failed to find path %s in the git tree of ID %s.",
+                  k.path(), commit.getTree().getId());
+              continue;
+            }
+            ObjectId id = tw.getObjectId(0);
+            Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
+            List<ContextLineInfo> contextLines = candidates.get(k);
+            Range r = k.range().get();
+            for (int i = r.start(); i <= r.end(); i++) {
+              contextLines.add(new ContextLineInfo(i, src.getString(i - 1)));
+            }
+          }
+        }
+      }
+    } catch (IOException e) {
+      throw new StorageException("Failed to load the comment context", e);
+    }
+  }
+
+  private static Optional<Range> getStartAndEndLines(CommentInfo comment) {
+    if (comment.range != null) {
+      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine));
+    } else if (comment.line != null) {
+      return Optional.of(Range.create(comment.line, comment.line));
+    }
+    return Optional.empty();
+  }
+
+  @AutoValue
+  abstract static class Range {
+    static Range create(int start, int end) {
+      return new AutoValue_CommentContextLoader_Range(start, end);
+    }
+
+    abstract int start();
+
+    abstract int end();
+  }
+
+  @AutoValue
+  abstract static class ContextData {
+    static ContextData create(String id, ObjectId commitId, String path, Optional<Range> range) {
+      return new AutoValue_CommentContextLoader_ContextData(id, commitId, path, range);
+    }
+
+    abstract String id();
+
+    abstract ObjectId commitId();
+
+    abstract String path();
+
+    abstract Optional<Range> range();
+  }
+}
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index e9ba72d..b752791 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -28,14 +28,15 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -47,12 +48,15 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /** Utility functions to manipulate Comments. */
 @Singleton
@@ -107,24 +111,30 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final String serverId;
+  private final PatchListCache patchListCache;
 
   @Inject
   CommentsUtil(
-      GitRepositoryManager repoManager, AllUsersName allUsers, @GerritServerId String serverId) {
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      @GerritServerId String serverId,
+      PatchListCache patchListCache) {
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.serverId = serverId;
+    this.patchListCache = patchListCache;
   }
 
-  public Comment newComment(
-      ChangeContext ctx,
+  public HumanComment newHumanComment(
+      ChangeNotes changeNotes,
+      CurrentUser currentUser,
+      Timestamp when,
       String path,
       PatchSet.Id psId,
       short side,
       String message,
       @Nullable Boolean unresolved,
-      @Nullable String parentUuid)
-      throws UnprocessableEntityException {
+      @Nullable String parentUuid) {
     if (unresolved == null) {
       if (parentUuid == null) {
         // Default to false if comment is not descended from another.
@@ -132,24 +142,24 @@
       } else {
         // Inherit unresolved value from inReplyTo comment if not specified.
         Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
-        Optional<Comment> parent = getPublished(ctx.getNotes(), key);
-        if (!parent.isPresent()) {
-          throw new UnprocessableEntityException("Invalid parentUuid supplied for comment");
-        }
-        unresolved = parent.get().unresolved;
+        Optional<HumanComment> parent = getPublishedHumanComment(changeNotes, key);
+
+        // If the comment was not found, it is descended from a robot comment, or the UUID is
+        // invalid. Either way, we use the default.
+        unresolved = parent.map(p -> p.unresolved).orElse(false);
       }
     }
-    Comment c =
-        new Comment(
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
-            ctx.getUser().getAccountId(),
-            ctx.getWhen(),
+            currentUser.getAccountId(),
+            when,
             side,
             message,
             serverId,
             unresolved);
     c.parentUuid = parentUuid;
-    ctx.getUser().updateRealAccountId(c::setRealAuthor);
+    currentUser.updateRealAccountId(c::setRealAuthor);
     return c;
   }
 
@@ -175,19 +185,27 @@
     return c;
   }
 
-  public Optional<Comment> getPublished(ChangeNotes notes, Comment.Key key) {
-    return publishedByChange(notes).stream().filter(c -> key.equals(c.key)).findFirst();
+  public Optional<HumanComment> getPublishedHumanComment(ChangeNotes notes, Comment.Key key) {
+    return publishedHumanCommentsByChange(notes).stream()
+        .filter(c -> key.equals(c.key))
+        .findFirst();
   }
 
-  public Optional<Comment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
+  public Optional<HumanComment> getPublishedHumanComment(ChangeNotes notes, String uuid) {
+    return publishedHumanCommentsByChange(notes).stream()
+        .filter(c -> c.key.uuid.equals(uuid))
+        .findFirst();
+  }
+
+  public Optional<HumanComment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
     return draftByChangeAuthor(notes, user.getAccountId()).stream()
         .filter(c -> key.equals(c.key))
         .findFirst();
   }
 
-  public List<Comment> publishedByChange(ChangeNotes notes) {
+  public List<HumanComment> publishedHumanCommentsByChange(ChangeNotes notes) {
     notes.load();
-    return sort(Lists.newArrayList(notes.getComments().values()));
+    return sort(Lists.newArrayList(notes.getHumanComments().values()));
   }
 
   public List<RobotComment> robotCommentsByChange(ChangeNotes notes) {
@@ -195,8 +213,12 @@
     return sort(Lists.newArrayList(notes.getRobotComments().values()));
   }
 
-  public List<Comment> draftByChange(ChangeNotes notes) {
-    List<Comment> comments = new ArrayList<>();
+  public Optional<RobotComment> getRobotComment(ChangeNotes notes, String uuid) {
+    return robotCommentsByChange(notes).stream().filter(c -> c.key.uuid.equals(uuid)).findFirst();
+  }
+
+  public List<HumanComment> draftByChange(ChangeNotes notes) {
+    List<HumanComment> comments = new ArrayList<>();
     for (Ref ref : getDraftRefs(notes.getChangeId())) {
       Account.Id account = Account.Id.fromRefSuffix(ref.getName());
       if (account != null) {
@@ -206,8 +228,8 @@
     return sort(comments);
   }
 
-  public List<Comment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    List<Comment> comments = new ArrayList<>();
+  public List<HumanComment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+    List<HumanComment> comments = new ArrayList<>();
     comments.addAll(publishedByPatchSet(notes, psId));
 
     for (Ref ref : getDraftRefs(notes.getChangeId())) {
@@ -219,13 +241,13 @@
     return sort(comments);
   }
 
-  public List<Comment> publishedByChangeFile(ChangeNotes notes, String file) {
-    return commentsOnFile(notes.load().getComments().values(), file);
+  public List<HumanComment> publishedByChangeFile(ChangeNotes notes, String file) {
+    return commentsOnFile(notes.load().getHumanComments().values(), file);
   }
 
-  public List<Comment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+  public List<HumanComment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
     return removeCommentsOnAncestorOfCommitMessage(
-        commentsOnPatchSet(notes.load().getComments().values(), psId));
+        commentsOnPatchSet(notes.load().getHumanComments().values(), psId));
   }
 
   public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
@@ -242,7 +264,9 @@
    * @param changeMessages list of change messages
    */
   public static void linkCommentsToChangeMessages(
-      List<? extends CommentInfo> comments, List<ChangeMessage> changeMessages) {
+      List<? extends CommentInfo> comments,
+      List<ChangeMessage> changeMessages,
+      boolean skipAutoGeneratedMessages) {
     ArrayList<ChangeMessage> sortedChangeMessages =
         changeMessages.stream()
             .sorted(comparing(ChangeMessage::getWrittenOn))
@@ -257,7 +281,7 @@
       // message in timestamp
       while (cmItr < sortedChangeMessages.size()) {
         ChangeMessage cm = sortedChangeMessages.get(cmItr);
-        if (isAfter(comment, cm) || skipChangeMessage(cm)) {
+        if (isAfter(comment, cm) || (skipAutoGeneratedMessages && isAutoGenerated(cm))) {
           cmItr += 1;
         } else {
           break;
@@ -269,8 +293,10 @@
     }
   }
 
-  private static boolean skipChangeMessage(ChangeMessage cm) {
-    return ChangeMessagesUtil.isAutogenerated(cm.getTag());
+  private static boolean isAutoGenerated(ChangeMessage cm) {
+    // Ignore Gerrit auto-generated messages, allowing to link against human change messages that
+    // have an auto-generated tag
+    return ChangeMessagesUtil.isAutogeneratedByGerrit(cm.getTag());
   }
 
   private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
@@ -284,29 +310,31 @@
    * auto-merge was done. From that time there may still be comments on the auto-merge commit
    * message and those we want to filter out.
    */
-  private List<Comment> removeCommentsOnAncestorOfCommitMessage(List<Comment> list) {
+  private List<HumanComment> removeCommentsOnAncestorOfCommitMessage(List<HumanComment> list) {
     return list.stream()
         .filter(c -> c.side != 0 || !Patch.COMMIT_MSG.equals(c.key.filename))
         .collect(toList());
   }
 
-  public List<Comment> draftByPatchSetAuthor(
+  public List<HumanComment> draftByPatchSetAuthor(
       PatchSet.Id psId, Account.Id author, ChangeNotes notes) {
     return commentsOnPatchSet(notes.load().getDraftComments(author).values(), psId);
   }
 
-  public List<Comment> draftByChangeFileAuthor(ChangeNotes notes, String file, Account.Id author) {
+  public List<HumanComment> draftByChangeFileAuthor(
+      ChangeNotes notes, String file, Account.Id author) {
     return commentsOnFile(notes.load().getDraftComments(author).values(), file);
   }
 
-  public List<Comment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
-    List<Comment> comments = new ArrayList<>();
+  public List<HumanComment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
+    List<HumanComment> comments = new ArrayList<>();
     comments.addAll(notes.getDraftComments(author).values());
     return sort(comments);
   }
 
-  public void putComments(ChangeUpdate update, Comment.Status status, Iterable<Comment> comments) {
-    for (Comment c : comments) {
+  public void putHumanComments(
+      ChangeUpdate update, HumanComment.Status status, Iterable<HumanComment> comments) {
+    for (HumanComment c : comments) {
       update.putComment(status, c);
     }
   }
@@ -317,8 +345,8 @@
     }
   }
 
-  public void deleteComments(ChangeUpdate update, Iterable<Comment> comments) {
-    for (Comment c : comments) {
+  public void deleteHumanComments(ChangeUpdate update, Iterable<HumanComment> comments) {
+    for (HumanComment c : comments) {
       update.deleteComment(c);
     }
   }
@@ -328,9 +356,10 @@
     update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
   }
 
-  private static List<Comment> commentsOnFile(Collection<Comment> allComments, String file) {
-    List<Comment> result = new ArrayList<>(allComments.size());
-    for (Comment c : allComments) {
+  private static List<HumanComment> commentsOnFile(
+      Collection<HumanComment> allComments, String file) {
+    List<HumanComment> result = new ArrayList<>(allComments.size());
+    for (HumanComment c : allComments) {
       String currentFilename = c.key.filename;
       if (currentFilename.equals(file)) {
         result.add(c);
@@ -350,23 +379,63 @@
     return sort(result);
   }
 
-  public static void setCommentCommitId(Comment c, PatchListCache cache, Change change, PatchSet ps)
-      throws PatchListNotAvailableException {
+  public void setCommentCommitId(Comment c, Change change, PatchSet ps) {
     checkArgument(
         c.key.patchSetId == ps.id().get(),
         "cannot set commit ID for patch set %s on comment %s",
         ps.id(),
         c);
     if (c.getCommitId() == null) {
-      if (Side.fromShort(c.side) == Side.PARENT) {
-        if (c.side < 0) {
-          c.setCommitId(cache.getOldId(change, ps, -c.side));
-        } else {
-          c.setCommitId(cache.getOldId(change, ps, null));
-        }
-      } else {
-        c.setCommitId(ps.commitId());
+      // This code is very much down into our stack and shouldn't be used for validation. Hence,
+      // don't throw an exception here if we can't find a commit for the indicated side but
+      // simply use the all-null ObjectId.
+      c.setCommitId(determineCommitId(change, ps, c.side).orElseGet(ObjectId::zeroId));
+    }
+  }
+
+  /**
+   * Determines the SHA-1 of the commit referenced by the (change, patchset, side) triple.
+   *
+   * @param change the change to which the commit belongs
+   * @param patchset the patchset to which the commit belongs
+   * @param side the side indicating which commit of the patchset to take. 1 is the patchset commit,
+   *     0 the parent commit (or auto-merge for changes representing merge commits); -x the xth
+   *     parent commit of a merge commit
+   * @return the commit SHA-1 or an empty {@link Optional} if the side isn't available for the given
+   *     change/patchset
+   * @throws StorageException if the SHA-1 is unavailable for an unknown reason
+   */
+  public Optional<ObjectId> determineCommitId(Change change, PatchSet patchset, short side) {
+    if (Side.fromShort(side) == Side.PARENT) {
+      if (side < 0) {
+        int parentNumber = Math.abs(side);
+        return resolveParentCommit(change.getProject(), patchset, parentNumber);
       }
+      return Optional.of(resolveAutoMergeCommit(change, patchset));
+    }
+    return Optional.of(patchset.commitId());
+  }
+
+  private Optional<ObjectId> resolveParentCommit(
+      Project.NameKey project, PatchSet patchset, int parentNumber) {
+    try (Repository repository = repoManager.openRepository(project)) {
+      RevCommit commit = repository.parseCommit(patchset.commitId());
+      if (commit.getParentCount() < parentNumber) {
+        return Optional.empty();
+      }
+      return Optional.of(commit.getParent(parentNumber - 1));
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private ObjectId resolveAutoMergeCommit(Change change, PatchSet patchset) {
+    try {
+      // TODO(ghareeb): Adjust after the auto-merge code was moved out of the diff caches. Also
+      // unignore the test in PortedCommentsIT.
+      return patchListCache.getOldId(change, patchset, null);
+    } catch (PatchListNotAvailableException e) {
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 9b2ba84..60f3d4b 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -14,13 +14,11 @@
 
 package com.google.gerrit.server;
 
-import static java.util.stream.Collectors.toList;
-
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -34,6 +32,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.HashSet;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -85,10 +84,10 @@
         new HashSet<>(allProjectsState.getCapabilityCollection().createGroup);
     Set<PermissionRule> createGroupsRef = new HashSet<>();
 
-    AccessSection allUsersCreateGroupAccessSection =
+    Optional<AccessSection> allUsersCreateGroupAccessSection =
         allUsersState.getConfig().getAccessSection(RefNames.REFS_GROUPS + "*");
-    if (allUsersCreateGroupAccessSection != null) {
-      Permission create = allUsersCreateGroupAccessSection.getPermission(Permission.CREATE);
+    if (allUsersCreateGroupAccessSection.isPresent()) {
+      Permission create = allUsersCreateGroupAccessSection.get().getPermission(Permission.CREATE);
       if (create != null && create.getRules() != null) {
         createGroupsRef.addAll(create.getRules());
       }
@@ -101,23 +100,25 @@
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsers)) {
       ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection createGroupAccessSection =
-          config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
-      if (createGroupsGlobal.isEmpty()) {
-        createGroupAccessSection.setPermissions(
-            createGroupAccessSection.getPermissions().stream()
-                .filter(p -> !Permission.CREATE.equals(p.getName()))
-                .collect(toList()));
-        config.replace(createGroupAccessSection);
-      } else {
-        // 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);
-      }
+      config.upsertAccessSection(
+          RefNames.REFS_GROUPS + "*",
+          refsGroupsAccessSectionBuilder -> {
+            if (createGroupsGlobal.isEmpty()) {
+              refsGroupsAccessSectionBuilder.modifyPermissions(
+                  permissions -> {
+                    permissions.removeIf(p -> Permission.CREATE.equals(p.getName()));
+                  });
+            } else {
+              // The create permission is managed by Gerrit at this point only so there is no
+              // concern of overwriting user-defined permissions here.
+              Permission.Builder createGroupPermission = Permission.builder(Permission.CREATE);
+              refsGroupsAccessSectionBuilder.remove(createGroupPermission);
+              refsGroupsAccessSectionBuilder.addPermission(createGroupPermission);
+              createGroupsGlobal.stream()
+                  .map(p -> p.toBuilder())
+                  .forEach(createGroupPermission::add);
+            }
+          });
 
       config.commit(md);
       projectCache.evictAndReindex(config.getProject());
diff --git a/java/com/google/gerrit/server/LibModuleType.java b/java/com/google/gerrit/server/LibModuleType.java
index 557f8c0..b9cb196 100644
--- a/java/com/google/gerrit/server/LibModuleType.java
+++ b/java/com/google/gerrit/server/LibModuleType.java
@@ -20,6 +20,9 @@
   /** Module for the sysInjector. */
   SYS_MODULE("Module"),
 
+  /** BatchModule for the sysInjector */
+  SYS_BATCH_MODULE("BatchModule"),
+
   /** Module for the dbInjector. */
   DB_MODULE("DbModule");
 
diff --git a/java/com/google/gerrit/server/ModuleOverloader.java b/java/com/google/gerrit/server/ModuleOverloader.java
index 9a8fe84..6b7b082 100644
--- a/java/com/google/gerrit/server/ModuleOverloader.java
+++ b/java/com/google/gerrit/server/ModuleOverloader.java
@@ -42,7 +42,7 @@
       return modules;
     }
 
-    // swipe cache implementation with alternative provided in lib
+    // swap module implementations with the matching alternative ones provided in lib
     return modules.stream()
         .map(
             m -> {
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index aeef2b6..005ae3b 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -22,9 +22,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 3d34d6b..4d19dd0 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -20,18 +20,14 @@
 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.HumanComment;
 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;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
@@ -45,22 +41,19 @@
 public class PublishCommentUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
   private final CommentsUtil commentsUtil;
 
   @Inject
-  PublishCommentUtil(
-      CommentsUtil commentsUtil, PatchListCache patchListCache, PatchSetUtil psUtil) {
+  PublishCommentUtil(CommentsUtil commentsUtil, PatchSetUtil psUtil) {
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   public void publish(
       ChangeContext ctx,
       ChangeUpdate changeUpdate,
-      Collection<Comment> draftComments,
+      Collection<HumanComment> draftComments,
       @Nullable String tag) {
     ChangeNotes notes = ctx.getNotes();
     checkArgument(notes != null);
@@ -70,8 +63,8 @@
 
     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) {
+    Set<HumanComment> commentsToPublish = new HashSet<>();
+    for (HumanComment draftComment : draftComments) {
       PatchSet.Id psIdOfDraftComment = psId(notes, draftComment);
       PatchSet ps = patchSets.get(psIdOfDraftComment);
       if (ps == null) {
@@ -102,17 +95,13 @@
       // Draft may have been created by a different real user; copy the current real user. (Only
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
       ctx.getUser().updateRealAccountId(draftComment::setRealAuthor);
-      try {
-        CommentsUtil.setCommentCommitId(draftComment, patchListCache, notes.getChange(), ps);
-      } catch (PatchListNotAvailableException e) {
-        throw new StorageException(e);
-      }
+      commentsUtil.setCommentCommitId(draftComment, notes.getChange(), ps);
       commentsToPublish.add(draftComment);
     }
-    commentsUtil.putComments(changeUpdate, Status.PUBLISHED, commentsToPublish);
+    commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, commentsToPublish);
   }
 
-  private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
+  private static PatchSet.Id psId(ChangeNotes notes, HumanComment c) {
     return PatchSet.id(notes.getChangeId(), c.key.patchSetId);
   }
 
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index df57629..358ce92 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -16,9 +16,10 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
@@ -31,6 +32,7 @@
 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.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -56,7 +58,7 @@
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
 
-  private List<Comment> comments = new ArrayList<>();
+  private List<HumanComment> comments = new ArrayList<>();
   private ChangeMessage message;
   private IdentifiedUser user;
 
@@ -114,7 +116,16 @@
     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();
+      RepoView repoView;
+      try {
+        repoView = ctx.getRepoView();
+      } catch (IOException ex) {
+        throw new StorageException(
+            String.format("Repository %s not found", ctx.getProject().get()), ex);
+      }
+      email
+          .create(notify, changeNotes, ps, user, message, comments, null, labelDelta, repoView)
+          .sendAsync();
     }
     commentAdded.fire(
         changeNotes.getChange(),
diff --git a/java/com/google/gerrit/server/ReviewerByEmailSet.java b/java/com/google/gerrit/server/ReviewerByEmailSet.java
index caae45e..4a317c3 100644
--- a/java/com/google/gerrit/server/ReviewerByEmailSet.java
+++ b/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Table;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import java.sql.Timestamp;
 
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 76d9471..e95bc1c 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -22,9 +22,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
-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;
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index a6143f4..3f7f3f2 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -17,9 +17,11 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -29,7 +31,6 @@
 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.AccountsSection;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -63,6 +64,10 @@
       this.accountVisibility = accountVisibility;
     }
 
+    /**
+     * Creates a {@link AccountControl} instance that checks whether the current user can see other
+     * accounts.
+     */
     public AccountControl get() {
       return new AccountControl(
           permissionBackend,
@@ -72,6 +77,21 @@
           userFactory,
           accountVisibility);
     }
+
+    /**
+     * Creates a {@link AccountControl} instance that checks whether the given user can see other
+     * accounts.
+     */
+    @UsedAt(UsedAt.Project.PLUGIN_CODE_OWNERS)
+    public AccountControl get(IdentifiedUser identifiedUser) {
+      return new AccountControl(
+          permissionBackend,
+          projectCache,
+          groupControlFactory,
+          identifiedUser,
+          userFactory,
+          accountVisibility);
+    }
   }
 
   private final AccountsSection accountsSection;
diff --git a/java/com/google/gerrit/server/account/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
index 63fa551..98b2ca9 100644
--- a/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -51,7 +51,10 @@
     STATE,
 
     /** Human friendly display name presented in the web interface chosen by the user. */
-    DISPLAY_NAME
+    DISPLAY_NAME,
+
+    /** Tags such as weather the account is a service user. */
+    TAGS
   }
 
   public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index 4d1d1b8..1845f5b 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.QueueProvider;
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index 9acf078..c260401 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -44,7 +44,8 @@
               FillOptions.DISPLAY_NAME,
               FillOptions.STATUS,
               FillOptions.STATE,
-              FillOptions.AVATARS));
+              FillOptions.AVATARS,
+              FillOptions.TAGS));
 
   public interface Factory {
     AccountLoader create(boolean detailed);
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 7a5b1aa..47c6efb 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -24,11 +24,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
@@ -328,6 +328,7 @@
               .getAllProjects()
               .getConfig()
               .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+              .orElseThrow(() -> new IllegalStateException("access section does not exist"))
               .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
 
       AccountGroup.UUID adminGroupUuid = admin.getRules().get(0).getGroup().getUUID();
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 4081a63..4dfeab5 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -570,6 +570,26 @@
         input, nameOrEmailSearchers, visibilitySupplierCanSee(), accountActivityPredicate());
   }
 
+  /**
+   * Same as {@link #resolveByNameOrEmail(String)}, but with exact matching for the full name, email
+   * and full name.
+   *
+   * @param input input string.
+   * @return a result describing matching accounts. Never null even if the result set is empty.
+   * @throws ConfigInvalidException if an error occurs.
+   * @throws IOException if an error occurs.
+   * @deprecated for use only by MailUtil for parsing commit footers; that class needs to be
+   *     reevaluated.
+   */
+  @Deprecated
+  public Result resolveByExactNameOrEmail(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input,
+        ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
+        visibilitySupplierCanSee(),
+        accountActivityPredicate());
+  }
+
   private Supplier<Predicate<AccountState>> visibilitySupplierCanSee() {
     return () -> accountControlFactory.get()::canSee;
   }
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index 1e9914d..b7a54f4 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -20,10 +20,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 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.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index 2eb5770..f23a766 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache;
@@ -80,7 +81,7 @@
   abstract Account account();
 
   /** Projects that the user has configured to watch. */
-  abstract ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+  abstract ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
       projectWatches();
 
   /** Preferences that this user has. Serialized as Git-config style string. */
@@ -88,7 +89,7 @@
 
   static CachedAccountDetails create(
       Account account,
-      ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+      ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
           projectWatches,
       CachedPreferences preferences) {
     return new AutoValue_CachedAccountDetails(account, projectWatches, preferences);
@@ -115,8 +116,8 @@
               .setMetaId(Strings.nullToEmpty(account.metaId()));
       serialized.setAccount(accountProto);
 
-      for (Map.Entry<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
-          watch : cachedAccountDetails.projectWatches().entrySet()) {
+      for (Map.Entry<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> watch :
+          cachedAccountDetails.projectWatches().entrySet()) {
         Cache.ProjectWatchProto.Builder proto =
             Cache.ProjectWatchProto.newBuilder().setProject(watch.getKey().project().get());
         if (watch.getKey().filter() != null) {
@@ -127,9 +128,7 @@
             .forEach(
                 n ->
                     proto.addNotifyType(
-                        Enums.stringConverter(ProjectWatches.NotifyType.class)
-                            .reverse()
-                            .convert(n)));
+                        Enums.stringConverter(NotifyConfig.NotifyType.class).reverse().convert(n)));
         serialized.addProjectWatchProto(proto);
       }
 
@@ -153,7 +152,7 @@
               .setMetaId(Strings.emptyToNull(proto.getAccount().getMetaId()))
               .build();
 
-      ImmutableMap.Builder<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+      ImmutableMap.Builder<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
           projectWatches = ImmutableMap.builder();
       proto.getProjectWatchProtoList().stream()
           .forEach(
@@ -162,9 +161,7 @@
                       ProjectWatches.ProjectWatchKey.create(
                           Project.nameKey(p.getProject()), p.getFilter()),
                       p.getNotifyTypeList().stream()
-                          .map(
-                              e ->
-                                  Enums.stringConverter(ProjectWatches.NotifyType.class).convert(e))
+                          .map(e -> Enums.stringConverter(NotifyConfig.NotifyType.class).convert(e))
                           .collect(toImmutableSet())));
 
       return CachedAccountDetails.create(
diff --git a/java/com/google/gerrit/server/account/CapabilityCollection.java b/java/com/google/gerrit/server/account/CapabilityCollection.java
index b52d616..7621929 100644
--- a/java/com/google/gerrit/server/account/CapabilityCollection.java
+++ b/java/com/google/gerrit/server/account/CapabilityCollection.java
@@ -18,12 +18,12 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-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.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.server.config.AdministrateServerGroups;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
@@ -60,9 +60,8 @@
     this.systemGroupBackend = systemGroupBackend;
 
     if (section == null) {
-      section = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
+      section = AccessSection.create(AccessSection.GLOBAL_CAPABILITIES);
     }
-
     Map<String, List<PermissionRule>> tmp = new HashMap<>();
     for (Permission permission : section.getPermissions()) {
       for (PermissionRule rule : permission.getRules()) {
@@ -111,7 +110,7 @@
 
     List<PermissionRule> r = new ArrayList<>(admins.size() + rules.size());
     for (GroupReference g : admins) {
-      r.add(new PermissionRule(g));
+      r.add(PermissionRule.create(g));
     }
     for (PermissionRule rule : rules) {
       if (!admins.contains(rule.getGroup())) {
@@ -142,9 +141,9 @@
     if (doesNotDeclare(section, capName)) {
       PermissionRange.WithDefaults range = GlobalCapability.getRange(capName);
       if (range != null) {
-        PermissionRule rule = new PermissionRule(group);
+        PermissionRule.Builder rule = PermissionRule.builder(group);
         rule.setRange(range.getDefaultMin(), range.getDefaultMax());
-        out.put(capName, Collections.singletonList(rule));
+        out.put(capName, Collections.singletonList(rule.build()));
       }
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupBackend.java b/java/com/google/gerrit/server/account/GroupBackend.java
index 3a874bb..545da6e 100644
--- a/java/com/google/gerrit/server/account/GroupBackend.java
+++ b/java/com/google/gerrit/server/account/GroupBackend.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.account;
 
 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.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.project.ProjectState;
diff --git a/java/com/google/gerrit/server/account/GroupBackends.java b/java/com/google/gerrit/server/account/GroupBackends.java
index 1b15512..26b3a82 100644
--- a/java/com/google/gerrit/server/account/GroupBackends.java
+++ b/java/com/google/gerrit/server/account/GroupBackends.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Collection;
 import java.util.Comparator;
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index 64fd7c6..d42db60 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -15,9 +15,9 @@
 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;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 3137c95..13b71cf 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
@@ -61,6 +62,7 @@
   private final IdentifiedUser.GenericFactory userFactory;
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
+  private final ServiceUserClassifier serviceUserClassifier;
 
   @Inject
   InternalAccountDirectory(
@@ -68,12 +70,14 @@
       DynamicItem<AvatarProvider> avatar,
       IdentifiedUser.GenericFactory userFactory,
       Provider<CurrentUser> self,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ServiceUserClassifier serviceUserClassifier) {
     this.accountCache = accountCache;
     this.avatar = avatar;
     this.userFactory = userFactory;
     this.self = self;
     this.permissionBackend = permissionBackend;
+    this.serviceUserClassifier = serviceUserClassifier;
   }
 
   @Override
@@ -155,6 +159,13 @@
       info.inactive = account.inactive() ? true : null;
     }
 
+    if (options.contains(FillOptions.TAGS)) {
+      info.tags =
+          serviceUserClassifier.isServiceUser(account.id())
+              ? ImmutableList.of(AccountInfo.Tag.SERVICE_USER)
+              : null;
+    }
+
     if (options.contains(FillOptions.AVATARS)) {
       AvatarProvider ap = avatar.get();
       if (ap != null) {
diff --git a/java/com/google/gerrit/server/account/InternalAccountUpdate.java b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
index bfbe917..4f9202f 100644
--- a/java/com/google/gerrit/server/account/InternalAccountUpdate.java
+++ b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
@@ -20,10 +20,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 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.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index ddd3da2..c520c96 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -17,9 +17,9 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index cf63346..42137c1 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -31,6 +31,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
 import java.util.ArrayList;
@@ -89,17 +90,6 @@
     public abstract @Nullable String filter();
   }
 
-  public enum NotifyType {
-    // sort by name, except 'ALL' which should stay last
-    ABANDONED_CHANGES,
-    ALL_COMMENTS,
-    NEW_CHANGES,
-    NEW_PATCHSETS,
-    SUBMITTED_CHANGES,
-
-    ALL
-  }
-
   public static final String FILTER_ALL = "*";
 
   public static final String WATCH_CONFIG = "watch.config";
@@ -110,7 +100,7 @@
   private final Config cfg;
   private final ValidationError.Sink validationErrorSink;
 
-  private ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches;
+  private ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> projectWatches;
 
   ProjectWatches(Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
     this.accountId = requireNonNull(accountId, "accountId");
@@ -118,7 +108,7 @@
     this.validationErrorSink = requireNonNull(validationErrorSink, "validationErrorSink");
   }
 
-  public ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> getProjectWatches() {
+  public ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> getProjectWatches() {
     if (projectWatches == null) {
       parse();
     }
@@ -152,9 +142,9 @@
    * @return the parsed project watches
    */
   @VisibleForTesting
-  public static ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> parse(
+  public static ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> parse(
       Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
-    Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
+    Map<ProjectWatchKey, Set<NotifyConfig.NotifyType>> projectWatches = new HashMap<>();
     for (String projectName : cfg.getSubsections(PROJECT)) {
       String[] notifyValues = cfg.getStringList(PROJECT, projectName, KEY_NOTIFY);
       for (String nv : notifyValues) {
@@ -171,7 +161,7 @@
         ProjectWatchKey key =
             ProjectWatchKey.create(Project.nameKey(projectName), notifyValue.filter());
         if (!projectWatches.containsKey(key)) {
-          projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
+          projectWatches.put(key, EnumSet.noneOf(NotifyConfig.NotifyType.class));
         }
         projectWatches.get(key).addAll(notifyValue.notifyTypes());
       }
@@ -179,7 +169,7 @@
     return immutableCopyOf(projectWatches);
   }
 
-  public Config save(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+  public Config save(Map<ProjectWatchKey, Set<NotifyConfig.NotifyType>> projectWatches) {
     this.projectWatches = immutableCopyOf(projectWatches);
 
     for (String projectName : cfg.getSubsections(PROJECT)) {
@@ -188,7 +178,7 @@
 
     ListMultimap<String, String> notifyValuesByProject =
         MultimapBuilder.hashKeys().arrayListValues().build();
-    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches.entrySet()) {
+    for (Map.Entry<ProjectWatchKey, Set<NotifyConfig.NotifyType>> e : projectWatches.entrySet()) {
       NotifyValue notifyValue = NotifyValue.create(e.getKey().filter(), e.getValue());
       notifyValuesByProject.put(e.getKey().project().get(), notifyValue.toString());
     }
@@ -200,9 +190,10 @@
     return cfg;
   }
 
-  private static ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> immutableCopyOf(
-      Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
-    ImmutableMap.Builder<ProjectWatchKey, ImmutableSet<NotifyType>> b = ImmutableMap.builder();
+  private static ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
+      immutableCopyOf(Map<ProjectWatchKey, Set<NotifyConfig.NotifyType>> projectWatches) {
+    ImmutableMap.Builder<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> b =
+        ImmutableMap.builder();
     projectWatches.entrySet().stream()
         .forEach(e -> b.put(e.getKey(), ImmutableSet.copyOf(e.getValue())));
     return b.build();
@@ -219,7 +210,7 @@
       int i = notifyValue.lastIndexOf('[');
       if (i < 0 || notifyValue.charAt(notifyValue.length() - 1) != ']') {
         validationErrorSink.error(
-            new ValidationError(
+            ValidationError.create(
                 WATCH_CONFIG,
                 String.format(
                     "Invalid project watch of account %d for project %s: %s",
@@ -231,16 +222,17 @@
         filter = null;
       }
 
-      Set<NotifyType> notifyTypes = EnumSet.noneOf(NotifyType.class);
+      Set<NotifyConfig.NotifyType> notifyTypes = EnumSet.noneOf(NotifyConfig.NotifyType.class);
       if (i + 1 < notifyValue.length() - 2) {
         for (String nt :
             Splitter.on(',')
                 .trimResults()
                 .splitToList(notifyValue.substring(i + 1, notifyValue.length() - 1))) {
-          NotifyType notifyType = Enums.getIfPresent(NotifyType.class, nt).orNull();
+          NotifyConfig.NotifyType notifyType =
+              Enums.getIfPresent(NotifyConfig.NotifyType.class, nt).orNull();
           if (notifyType == null) {
             validationErrorSink.error(
-                new ValidationError(
+                ValidationError.create(
                     WATCH_CONFIG,
                     String.format(
                         "Invalid notify type %s in project watch "
@@ -254,18 +246,19 @@
       return create(filter, notifyTypes);
     }
 
-    public static NotifyValue create(@Nullable String filter, Collection<NotifyType> notifyTypes) {
+    public static NotifyValue create(
+        @Nullable String filter, Collection<NotifyConfig.NotifyType> notifyTypes) {
       return new AutoValue_ProjectWatches_NotifyValue(
           Strings.emptyToNull(filter), Sets.immutableEnumSet(notifyTypes));
     }
 
     public abstract @Nullable String filter();
 
-    public abstract ImmutableSet<NotifyType> notifyTypes();
+    public abstract ImmutableSet<NotifyConfig.NotifyType> notifyTypes();
 
     @Override
     public final String toString() {
-      List<NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
+      List<NotifyConfig.NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
       StringBuilder notifyValue = new StringBuilder();
       notifyValue.append(firstNonNull(filter(), FILTER_ALL)).append(" [");
       Joiner.on(", ").appendTo(notifyValue, notifyTypes);
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifier.java b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
new file mode 100644
index 0000000..c8314c8
--- /dev/null
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifier.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.account;
+
+import com.google.gerrit.entities.Account;
+
+public interface ServiceUserClassifier {
+  /** Returns {@code true} if the given user is considered a {@code Service User} user. */
+  boolean isServiceUser(Account.Id user);
+
+  /** An instance that can be used for testing and will consider no user to be a Service User. */
+  class NoOp implements ServiceUserClassifier {
+    @Override
+    public boolean isServiceUser(Account.Id user) {
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
new file mode 100644
index 0000000..255467c
--- /dev/null
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import javax.inject.Inject;
+
+/**
+ * An implementation of {@link ServiceUserClassifier} that will consider a user to be a robot if
+ * they are a member in the {@code Service Users} group.
+ */
+@Singleton
+public class ServiceUserClassifierImpl implements ServiceUserClassifier {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static Module module() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        bind(ServiceUserClassifier.class).to(ServiceUserClassifierImpl.class).in(Scopes.SINGLETON);
+      }
+    };
+  }
+
+  private final GroupCache groupCache;
+  private final InternalGroupBackend internalGroupBackend;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+
+  @Inject
+  ServiceUserClassifierImpl(
+      GroupCache groupCache,
+      InternalGroupBackend internalGroupBackend,
+      IdentifiedUser.GenericFactory identifiedUserFactory) {
+    this.groupCache = groupCache;
+    this.internalGroupBackend = internalGroupBackend;
+    this.identifiedUserFactory = identifiedUserFactory;
+  }
+
+  @Override
+  public boolean isServiceUser(Account.Id user) {
+    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey("Service Users"));
+    if (!maybeGroup.isPresent()) {
+      return false;
+    }
+    List<AccountGroup.UUID> toTraverse = new ArrayList<>();
+    toTraverse.add(maybeGroup.get().getGroupUUID());
+    Set<AccountGroup.UUID> seen = new HashSet<>();
+    while (!toTraverse.isEmpty()) {
+      InternalGroup currentGroup =
+          groupCache
+              .get(toTraverse.remove(0))
+              .orElseThrow(() -> new IllegalStateException("invalid subgroup"));
+      if (seen.contains(currentGroup.getGroupUUID())) {
+        logger.atWarning().log(
+            "Skipping %s because it's a cyclic subgroup", currentGroup.getGroupUUID());
+        continue;
+      }
+      seen.add(currentGroup.getGroupUUID());
+      if (currentGroup.getMembers().contains(user)) {
+        // The user is a member of the 'Service Users' group or a subgroup.
+        return true;
+      }
+      boolean hasExternalSubgroup =
+          currentGroup.getSubgroups().stream().anyMatch(g -> !internalGroupBackend.handles(g));
+      if (hasExternalSubgroup) {
+        // 'Service Users or a subgroup of Service User' contains an external subgroup, so we have
+        // to default to the more expensive evaluation of getting all of the user's group
+        // memberships.
+        return identifiedUserFactory
+            .create(user)
+            .getEffectiveGroups()
+            .contains(maybeGroup.get().getGroupUUID());
+      }
+      toTraverse.addAll(currentGroup.getSubgroups());
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/account/StoredPreferences.java b/java/com/google/gerrit/server/account/StoredPreferences.java
index 1b3ff40..79be9e5 100644
--- a/java/com/google/gerrit/server/account/StoredPreferences.java
+++ b/java/com/google/gerrit/server/account/StoredPreferences.java
@@ -15,19 +15,13 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-import static com.google.gerrit.server.config.ConfigUtil.skipField;
 import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -36,13 +30,12 @@
 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.PreferencesParserUtil;
 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 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;
@@ -76,8 +69,6 @@
  * <p>The preferences are lazily parsed.
  */
 public class StoredPreferences {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public static final String PREFERENCES_CONFIG = "preferences.config";
 
   private final Account.Id accountId;
@@ -141,7 +132,7 @@
           UserConfigSections.GENERAL,
           null,
           mergedGeneralPreferencesInput,
-          parseDefaultGeneralPreferences(defaultCfg, null));
+          PreferencesParserUtil.parseDefaultGeneralPreferences(defaultCfg, null));
       setChangeTable(cfg, mergedGeneralPreferencesInput.changeTable);
       setMy(cfg, mergedGeneralPreferencesInput.my);
 
@@ -158,7 +149,7 @@
           UserConfigSections.DIFF,
           null,
           mergedDiffPreferencesInput,
-          parseDefaultDiffPreferences(defaultCfg, null));
+          PreferencesParserUtil.parseDefaultDiffPreferences(defaultCfg, null));
 
       // evict the cached diff preferences
       this.diffPreferences = null;
@@ -173,7 +164,7 @@
           UserConfigSections.EDIT,
           null,
           mergedEditPreferencesInput,
-          parseDefaultEditPreferences(defaultCfg, null));
+          PreferencesParserUtil.parseDefaultEditPreferences(defaultCfg, null));
 
       // evict the cached edit preferences
       this.editPreferences = null;
@@ -189,10 +180,10 @@
 
   private GeneralPreferencesInfo parseGeneralPreferences(@Nullable GeneralPreferencesInfo input) {
     try {
-      return parseGeneralPreferences(cfg, defaultCfg, input);
+      return PreferencesParserUtil.parseGeneralPreferences(cfg, defaultCfg, input);
     } catch (ConfigInvalidException e) {
       validationErrorSink.error(
-          new ValidationError(
+          ValidationError.create(
               PREFERENCES_CONFIG,
               String.format(
                   "Invalid general preferences for account %d: %s",
@@ -203,10 +194,10 @@
 
   private DiffPreferencesInfo parseDiffPreferences(@Nullable DiffPreferencesInfo input) {
     try {
-      return parseDiffPreferences(cfg, defaultCfg, input);
+      return PreferencesParserUtil.parseDiffPreferences(cfg, defaultCfg, input);
     } catch (ConfigInvalidException e) {
       validationErrorSink.error(
-          new ValidationError(
+          ValidationError.create(
               PREFERENCES_CONFIG,
               String.format(
                   "Invalid diff preferences for account %d: %s", accountId.get(), e.getMessage())));
@@ -216,10 +207,10 @@
 
   private EditPreferencesInfo parseEditPreferences(@Nullable EditPreferencesInfo input) {
     try {
-      return parseEditPreferences(cfg, defaultCfg, input);
+      return PreferencesParserUtil.parseEditPreferences(cfg, defaultCfg, input);
     } catch (ConfigInvalidException e) {
       validationErrorSink.error(
-          new ValidationError(
+          ValidationError.create(
               PREFERENCES_CONFIG,
               String.format(
                   "Invalid edit preferences for account %d: %s", accountId.get(), e.getMessage())));
@@ -227,218 +218,6 @@
     }
   }
 
-  /**
-   * 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 =
-        loadSection(
-            cfg,
-            UserConfigSections.GENERAL,
-            null,
-            new GeneralPreferencesInfo(),
-            defaultCfg != null
-                ? parseDefaultGeneralPreferences(defaultCfg, input)
-                : GeneralPreferencesInfo.defaults(),
-            input);
-    if (input != null) {
-      r.changeTable = input.changeTable;
-      r.my = input.my;
-    } else {
-      r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
-      r.my = parseMyMenus(cfg, defaultCfg);
-    }
-    return r;
-  }
-
-  /**
-   * 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(
-        cfg,
-        UserConfigSections.DIFF,
-        null,
-        new DiffPreferencesInfo(),
-        defaultCfg != null
-            ? parseDefaultDiffPreferences(defaultCfg, input)
-            : DiffPreferencesInfo.defaults(),
-        input);
-  }
-
-  /**
-   * 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(
-        cfg,
-        UserConfigSections.EDIT,
-        null,
-        new EditPreferencesInfo(),
-        defaultCfg != null
-            ? parseDefaultEditPreferences(defaultCfg, input)
-            : EditPreferencesInfo.defaults(),
-        input);
-  }
-
-  private static GeneralPreferencesInfo parseDefaultGeneralPreferences(
-      Config defaultCfg, GeneralPreferencesInfo input) throws ConfigInvalidException {
-    GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
-    loadSection(
-        defaultCfg,
-        UserConfigSections.GENERAL,
-        null,
-        allUserPrefs,
-        GeneralPreferencesInfo.defaults(),
-        input);
-    return updateGeneralPreferencesDefaults(allUserPrefs);
-  }
-
-  private static DiffPreferencesInfo parseDefaultDiffPreferences(
-      Config defaultCfg, DiffPreferencesInfo input) throws ConfigInvalidException {
-    DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
-    loadSection(
-        defaultCfg,
-        UserConfigSections.DIFF,
-        null,
-        allUserPrefs,
-        DiffPreferencesInfo.defaults(),
-        input);
-    return updateDiffPreferencesDefaults(allUserPrefs);
-  }
-
-  private static EditPreferencesInfo parseDefaultEditPreferences(
-      Config defaultCfg, EditPreferencesInfo input) throws ConfigInvalidException {
-    EditPreferencesInfo allUserPrefs = new EditPreferencesInfo();
-    loadSection(
-        defaultCfg,
-        UserConfigSections.EDIT,
-        null,
-        allUserPrefs,
-        EditPreferencesInfo.defaults(),
-        input);
-    return updateEditPreferencesDefaults(allUserPrefs);
-  }
-
-  private static GeneralPreferencesInfo updateGeneralPreferencesDefaults(
-      GeneralPreferencesInfo input) {
-    GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      logger.atSevere().withCause(e).log("Failed to apply default general preferences");
-      return GeneralPreferencesInfo.defaults();
-    }
-    return result;
-  }
-
-  private static DiffPreferencesInfo updateDiffPreferencesDefaults(DiffPreferencesInfo input) {
-    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      logger.atSevere().withCause(e).log("Failed to apply default diff preferences");
-      return DiffPreferencesInfo.defaults();
-    }
-    return result;
-  }
-
-  private static EditPreferencesInfo updateEditPreferencesDefaults(EditPreferencesInfo input) {
-    EditPreferencesInfo result = EditPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      logger.atSevere().withCause(e).log("Failed to apply default edit preferences");
-      return EditPreferencesInfo.defaults();
-    }
-    return result;
-  }
-
-  private static List<String> parseChangeTableColumns(Config cfg, @Nullable Config defaultCfg) {
-    List<String> changeTable = changeTable(cfg);
-    if (changeTable == null && defaultCfg != null) {
-      changeTable = changeTable(defaultCfg);
-    }
-    return changeTable;
-  }
-
-  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
-    List<MenuItem> my = my(cfg);
-    if (my.isEmpty() && defaultCfg != null) {
-      my = my(defaultCfg);
-    }
-    if (my.isEmpty()) {
-      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));
-      my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
-      my.add(new MenuItem("Groups", "#/settings/#Groups", null));
-    }
-    return my;
-  }
-
-  public static GeneralPreferencesInfo readDefaultGeneralPreferences(
-      AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return parseGeneralPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
-  }
-
-  public static DiffPreferencesInfo readDefaultDiffPreferences(
-      AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return parseDiffPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
-  }
-
-  public static EditPreferencesInfo readDefaultEditPreferences(
-      AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return parseEditPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
-  }
-
-  static Config readDefaultConfig(AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
-    defaultPrefs.load(allUsersName, allUsersRepo);
-    return defaultPrefs.getConfig();
-  }
-
   public static GeneralPreferencesInfo updateDefaultGeneralPreferences(
       MetaDataUpdate md, GeneralPreferencesInfo input) throws IOException, ConfigInvalidException {
     VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
@@ -453,7 +232,7 @@
     setChangeTable(defaultPrefs.getConfig(), input.changeTable);
     defaultPrefs.commit(md);
 
-    return parseGeneralPreferences(defaultPrefs.getConfig(), null, null);
+    return PreferencesParserUtil.parseGeneralPreferences(defaultPrefs.getConfig(), null, null);
   }
 
   public static DiffPreferencesInfo updateDefaultDiffPreferences(
@@ -468,7 +247,7 @@
         DiffPreferencesInfo.defaults());
     defaultPrefs.commit(md);
 
-    return parseDiffPreferences(defaultPrefs.getConfig(), null, null);
+    return PreferencesParserUtil.parseDiffPreferences(defaultPrefs.getConfig(), null, null);
   }
 
   public static EditPreferencesInfo updateDefaultEditPreferences(
@@ -483,11 +262,24 @@
         EditPreferencesInfo.defaults());
     defaultPrefs.commit(md);
 
-    return parseEditPreferences(defaultPrefs.getConfig(), null, null);
+    return PreferencesParserUtil.parseEditPreferences(defaultPrefs.getConfig(), null, null);
   }
 
-  private static List<String> changeTable(Config cfg) {
-    return Lists.newArrayList(cfg.getStringList(CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
+  public static void validateMy(List<MenuItem> my) throws BadRequestException {
+    if (my == null) {
+      return;
+    }
+    for (MenuItem item : my) {
+      checkRequiredMenuItemField(item.name, "name");
+      checkRequiredMenuItemField(item.url, "URL");
+    }
+  }
+
+  static Config readDefaultConfig(AllUsersName allUsersName, Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
+    defaultPrefs.load(allUsersName, allUsersRepo);
+    return defaultPrefs.getConfig();
   }
 
   private static void setChangeTable(Config cfg, List<String> changeTable) {
@@ -497,21 +289,6 @@
     }
   }
 
-  private static List<MenuItem> my(Config cfg) {
-    List<MenuItem> my = new ArrayList<>();
-    for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
-      String url = my(cfg, subsection, KEY_URL, "#/");
-      String target = my(cfg, subsection, KEY_TARGET, url.startsWith("#") ? null : "_blank");
-      my.add(new MenuItem(subsection, url, target, my(cfg, subsection, KEY_ID, null)));
-    }
-    return my;
-  }
-
-  private static String my(Config cfg, String subsection, String key, String defaultValue) {
-    String val = cfg.getString(UserConfigSections.MY, subsection, key);
-    return !Strings.isNullOrEmpty(val) ? val : defaultValue;
-  }
-
   private static void setMy(Config cfg, List<MenuItem> my) {
     if (my != null) {
       unsetSection(cfg, UserConfigSections.MY);
@@ -526,16 +303,6 @@
     }
   }
 
-  public static void validateMy(List<MenuItem> my) throws BadRequestException {
-    if (my == null) {
-      return;
-    }
-    for (MenuItem item : my) {
-      checkRequiredMenuItemField(item.name, "name");
-      checkRequiredMenuItemField(item.url, "URL");
-    }
-  }
-
   private static void checkRequiredMenuItemField(String value, String name)
       throws BadRequestException {
     if (isNullOrEmpty(value)) {
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index fddbd2b..a35b0ac 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -24,9 +24,9 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 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.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
diff --git a/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
index 8dc44b7..6c79296 100644
--- a/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
@@ -17,7 +17,7 @@
 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.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.AttentionSetEntryResource;
 import com.google.gerrit.server.restapi.change.RemoveFromAttentionSet;
@@ -41,7 +41,7 @@
   }
 
   @Override
-  public void remove(RemoveFromAttentionSetInput input) throws RestApiException {
+  public void remove(AttentionSetInput input) throws RestApiException {
     try {
       removeFromAttentionSet.apply(attentionSetEntryResource, input);
     } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 5122f8a..0992bcd 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -23,9 +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.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
@@ -158,7 +158,7 @@
   private final GetAssignee getAssignee;
   private final GetPastAssignees getPastAssignees;
   private final DeleteAssignee deleteAssignee;
-  private final ListChangeComments listComments;
+  private final Provider<ListChangeComments> listCommentsProvider;
   private final ListChangeRobotComments listChangeRobotComments;
   private final ListChangeDrafts listDrafts;
   private final ChangeEditApiImpl.Factory changeEditApi;
@@ -211,7 +211,7 @@
       GetAssignee getAssignee,
       GetPastAssignees getPastAssignees,
       DeleteAssignee deleteAssignee,
-      ListChangeComments listComments,
+      Provider<ListChangeComments> listCommentsProvider,
       ListChangeRobotComments listChangeRobotComments,
       ListChangeDrafts listDrafts,
       ChangeEditApiImpl.Factory changeEditApi,
@@ -262,7 +262,7 @@
     this.getAssignee = getAssignee;
     this.getPastAssignees = getPastAssignees;
     this.deleteAssignee = deleteAssignee;
-    this.listComments = listComments;
+    this.listCommentsProvider = listCommentsProvider;
     this.listChangeRobotComments = listChangeRobotComments;
     this.listDrafts = listDrafts;
     this.changeEditApi = changeEditApi;
@@ -543,7 +543,7 @@
   }
 
   @Override
-  public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+  public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException {
     try {
       return addToAttentionSet.apply(change, input).value();
     } catch (Exception e) {
@@ -599,21 +599,30 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> comments() throws RestApiException {
-    try {
-      return listComments.apply(change).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get comments", e);
-    }
-  }
+  public CommentsRequest commentsRequest() throws RestApiException {
+    return new CommentsRequest() {
+      @Override
+      public Map<String, List<CommentInfo>> get() throws RestApiException {
+        try {
+          ListChangeComments listComments = listCommentsProvider.get();
+          listComments.setContext(this.getContext());
+          return listComments.apply(change).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get comments", e);
+        }
+      }
 
-  @Override
-  public List<CommentInfo> commentsAsList() throws RestApiException {
-    try {
-      return listComments.getComments(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get comments", e);
-    }
+      @Override
+      public List<CommentInfo> getAsList() throws RestApiException {
+        try {
+          ListChangeComments listComments = listCommentsProvider.get();
+          listComments.setContext(this.getContext());
+          return listComments.getComments(change);
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get comments", e);
+        }
+      }
+    };
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
index c5fcab1..35dd9c1 100644
--- a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.restapi.change.DeleteComment;
 import com.google.gerrit.server.restapi.change.GetComment;
 import com.google.inject.Inject;
@@ -28,16 +28,16 @@
 
 class CommentApiImpl implements CommentApi {
   interface Factory {
-    CommentApiImpl create(CommentResource c);
+    CommentApiImpl create(HumanCommentResource c);
   }
 
   private final GetComment getComment;
   private final DeleteComment deleteComment;
-  private final CommentResource comment;
+  private final HumanCommentResource comment;
 
   @Inject
   CommentApiImpl(
-      GetComment getComment, DeleteComment deleteComment, @Assisted CommentResource comment) {
+      GetComment getComment, DeleteComment deleteComment, @Assisted HumanCommentResource comment) {
     this.getComment = getComment;
     this.deleteComment = deleteComment;
     this.comment = comment;
diff --git a/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index c506b2e..cf9f243 100644
--- a/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -123,9 +123,6 @@
     if (r.getBase() != null) {
       getDiff.setBase(r.getBase());
     }
-    if (r.getContext() != null) {
-      getDiff.setContext(r.getContext());
-    }
     if (r.getIntraline() != null) {
       getDiff.setIntraline(r.getIntraline());
     }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index b515dfe..04d2e8ae 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -79,6 +79,8 @@
 import com.google.gerrit.server.restapi.change.GetPatch;
 import com.google.gerrit.server.restapi.change.GetRelated;
 import com.google.gerrit.server.restapi.change.GetRevisionActions;
+import com.google.gerrit.server.restapi.change.ListPortedComments;
+import com.google.gerrit.server.restapi.change.ListPortedDrafts;
 import com.google.gerrit.server.restapi.change.ListRevisionComments;
 import com.google.gerrit.server.restapi.change.ListRevisionDrafts;
 import com.google.gerrit.server.restapi.change.ListRobotComments;
@@ -130,6 +132,8 @@
   private final FileApiImpl.Factory fileApi;
   private final ListRevisionComments listComments;
   private final ListRobotComments listRobotComments;
+  private final ListPortedComments listPortedComments;
+  private final ListPortedDrafts listPortedDrafts;
   private final ApplyFix applyFix;
   private final GetFixPreview getFixPreview;
   private final Fixes fixes;
@@ -175,6 +179,8 @@
       FileApiImpl.Factory fileApi,
       ListRevisionComments listComments,
       ListRobotComments listRobotComments,
+      ListPortedComments listPortedComments,
+      ListPortedDrafts listPortedDrafts,
       ApplyFix applyFix,
       GetFixPreview getFixPreview,
       Fixes fixes,
@@ -219,6 +225,8 @@
     this.listComments = listComments;
     this.robotComments = robotComments;
     this.listRobotComments = listRobotComments;
+    this.listPortedComments = listPortedComments;
+    this.listPortedDrafts = listPortedDrafts;
     this.applyFix = applyFix;
     this.getFixPreview = getFixPreview;
     this.fixes = fixes;
@@ -454,6 +462,24 @@
   }
 
   @Override
+  public Map<String, List<CommentInfo>> portedComments() throws RestApiException {
+    try {
+      return listPortedComments.apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve ported comments", e);
+    }
+  }
+
+  @Override
+  public Map<String, List<CommentInfo>> portedDrafts() throws RestApiException {
+    try {
+      return listPortedDrafts.apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve ported draft comments", e);
+    }
+  }
+
+  @Override
   public EditInfo applyFix(String fixId) throws RestApiException {
     try {
       return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index 20e8441..e88f6df 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -16,9 +16,9 @@
 
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
diff --git a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
index c2123cb..63cd426 100644
--- a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
+++ b/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
@@ -17,9 +17,9 @@
 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.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 0d8f3f8..cd1e0a4 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -24,10 +24,10 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
@@ -91,7 +91,7 @@
 
   private static GroupReference groupReference(ParameterizedString p, LdapQuery.Result res)
       throws NamingException {
-    return new GroupReference(
+    return GroupReference.create(
         AccountGroup.uuid(LDAP_UUID + res.getDN()), LDAP_NAME + LdapRealm.apply(p, res));
   }
 
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 1421f17..b5972e2 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -20,10 +20,10 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.server.account.AbstractRealm;
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 3bb88e5..03ecd91 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -40,15 +40,32 @@
 
   private final DynamicItem<OAuthTokenEncrypter> encrypter;
 
+  public enum AccountIdSerializer implements CacheSerializer<Account.Id> {
+    INSTANCE;
+
+    private final Converter<Account.Id, Integer> converter =
+        Converter.from(Account.Id::get, Account::id);
+
+    private final Converter<Integer, Account.Id> reverse = converter.reverse();
+
+    @Override
+    public byte[] serialize(Account.Id object) {
+      return IntegerCacheSerializer.INSTANCE.serialize(converter.convert(object));
+    }
+
+    @Override
+    public Account.Id deserialize(byte[] in) {
+      return reverse.convert(IntegerCacheSerializer.INSTANCE.deserialize(in));
+    }
+  }
+
   public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
         persist(OAUTH_TOKENS, Account.Id.class, OAuthToken.class)
             .version(1)
-            .keySerializer(
-                CacheSerializer.convert(
-                    IntegerCacheSerializer.INSTANCE, Converter.from(Account.Id::get, Account::id)))
+            .keySerializer(AccountIdSerializer.INSTANCE)
             .valueSerializer(new Serializer());
       }
     };
diff --git a/java/com/google/gerrit/server/cache/CacheBinding.java b/java/com/google/gerrit/server/cache/CacheBinding.java
index 9d90d073..99db64e 100644
--- a/java/com/google/gerrit/server/cache/CacheBinding.java
+++ b/java/com/google/gerrit/server/cache/CacheBinding.java
@@ -29,6 +29,13 @@
   /** Set the time an element lives after last access before being expired. */
   CacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration);
 
+  /**
+   * Set the time that an element will be refreshed after. Elements older than this but younger than
+   * {@link #expireAfterWrite(Duration)} will still be returned, but on access a task is queued to
+   * refresh their value asynchronously.
+   */
+  CacheBinding<K, V> refreshAfterWrite(Duration duration);
+
   /** Populate the cache with items from the CacheLoader. */
   CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
 
diff --git a/java/com/google/gerrit/server/cache/CacheDef.java b/java/com/google/gerrit/server/cache/CacheDef.java
index d0c633e..31a453e 100644
--- a/java/com/google/gerrit/server/cache/CacheDef.java
+++ b/java/com/google/gerrit/server/cache/CacheDef.java
@@ -51,6 +51,9 @@
   Duration expireFromMemoryAfterAccess();
 
   @Nullable
+  Duration refreshAfterWrite();
+
+  @Nullable
   Weigher<K, V> weigher();
 
   @Nullable
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index fe4244c..2dd9e1f 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -38,6 +38,7 @@
   private long maximumWeight;
   private Duration expireAfterWrite;
   private Duration expireFromMemoryAfterAccess;
+  private Duration refreshAfterWrite;
   private Provider<CacheLoader<K, V>> loader;
   private Provider<Weigher<K, V>> weigher;
 
@@ -90,6 +91,13 @@
   }
 
   @Override
+  public CacheBinding<K, V> refreshAfterWrite(Duration duration) {
+    checkNotFrozen();
+    refreshAfterWrite = duration;
+    return this;
+  }
+
+  @Override
   public CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> impl) {
     checkNotFrozen();
     loader = module.bindCacheLoader(this, impl);
@@ -151,6 +159,11 @@
   }
 
   @Override
+  public Duration refreshAfterWrite() {
+    return refreshAfterWrite;
+  }
+
+  @Override
   @Nullable
   public Weigher<K, V> weigher() {
     return weigher != null ? weigher.get() : null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index 5d9ce60..aa62745 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -43,6 +43,11 @@
   }
 
   @Override
+  public Duration refreshAfterWrite() {
+    return source.refreshAfterWrite();
+  }
+
+  @Override
   public Weigher<K, V> weigher() {
     Weigher<K, V> weigher = source.weigher();
     if (weigher == null) {
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 8f7e360..82615a4 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -237,6 +237,7 @@
         def.valueSerializer(),
         def.version(),
         maxSize,
-        def.expireAfterWrite());
+        def.expireAfterWrite(),
+        def.expireFromMemoryAfterAccess());
   }
 }
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index ef4e44c..7a53600 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -23,6 +23,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.BloomFilter;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -40,6 +43,7 @@
 import java.sql.Statement;
 import java.sql.Timestamp;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.Calendar;
 import java.util.Map;
 import java.util.concurrent.ArrayBlockingQueue;
@@ -122,7 +126,12 @@
   @Override
   public V get(K key) throws ExecutionException {
     if (mem instanceof LoadingCache) {
-      return ((LoadingCache<K, ValueHolder<V>>) mem).get(key).value;
+      LoadingCache<K, ValueHolder<V>> asLoadingCache = (LoadingCache<K, ValueHolder<V>>) mem;
+      ValueHolder<V> valueHolder = asLoadingCache.get(key);
+      if (store.needsRefresh(valueHolder.created)) {
+        asLoadingCache.refresh(key);
+      }
+      return valueHolder.value;
     }
     throw new UnsupportedOperationException();
   }
@@ -139,8 +148,8 @@
                 }
               }
 
-              ValueHolder<V> h = new ValueHolder<>(valueLoader.call());
-              h.created = TimeUtil.nowMs();
+              ValueHolder<V> h =
+                  new ValueHolder<>(valueLoader.call(), Instant.ofEpochMilli(TimeUtil.nowMs()));
               executor.execute(() -> store.put(key, h));
               return h;
             })
@@ -149,8 +158,7 @@
 
   @Override
   public void put(K key, V val) {
-    final ValueHolder<V> h = new ValueHolder<>(val);
-    h.created = TimeUtil.nowMs();
+    final ValueHolder<V> h = new ValueHolder<>(val, Instant.ofEpochMilli(TimeUtil.nowMs()));
     mem.put(key, h);
     executor.execute(() -> store.put(key, h));
   }
@@ -217,11 +225,12 @@
 
   static class ValueHolder<V> {
     final V value;
-    long created;
+    final Instant created;
     volatile boolean clean;
 
-    ValueHolder(V value) {
+    ValueHolder(V value, Instant created) {
       this.value = value;
+      this.created = created;
     }
   }
 
@@ -248,12 +257,34 @@
           }
         }
 
-        final ValueHolder<V> h = new ValueHolder<>(loader.load(key));
-        h.created = TimeUtil.nowMs();
+        final ValueHolder<V> h =
+            new ValueHolder<>(loader.load(key), Instant.ofEpochMilli(TimeUtil.nowMs()));
         executor.execute(() -> store.put(key, h));
         return h;
       }
     }
+
+    @Override
+    public ListenableFuture<ValueHolder<V>> reload(K key, ValueHolder<V> oldValue)
+        throws Exception {
+      ListenableFuture<V> reloadedValue = loader.reload(key, oldValue.value);
+      Futures.addCallback(
+          reloadedValue,
+          new FutureCallback<V>() {
+            @Override
+            public void onSuccess(V result) {
+              store.put(key, new ValueHolder<>(result, TimeUtil.now()));
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+              logger.atWarning().withCause(t).log("Unable to reload cache value");
+            }
+          },
+          executor);
+
+      return Futures.transform(reloadedValue, v -> new ValueHolder<>(v, TimeUtil.now()), executor);
+    }
   }
 
   static class SqlStore<K, V> {
@@ -263,6 +294,7 @@
     private final int version;
     private final long maxSize;
     @Nullable private final Duration expireAfterWrite;
+    @Nullable private final Duration refreshAfterWrite;
     private final BlockingQueue<SqlHandle> handles;
     private final AtomicLong hitCount = new AtomicLong();
     private final AtomicLong missCount = new AtomicLong();
@@ -276,13 +308,15 @@
         CacheSerializer<V> valueSerializer,
         int version,
         long maxSize,
-        @Nullable Duration expireAfterWrite) {
+        @Nullable Duration expireAfterWrite,
+        @Nullable Duration refreshAfterWrite) {
       this.url = jdbcUrl;
       this.keyType = createKeyType(keyType, keySerializer);
       this.valueSerializer = valueSerializer;
       this.version = version;
       this.maxSize = maxSize;
       this.expireAfterWrite = expireAfterWrite;
+      this.refreshAfterWrite = refreshAfterWrite;
 
       int cores = Runtime.getRuntime().availableProcessors();
       int keep = Math.min(cores, 16);
@@ -394,14 +428,14 @@
           }
 
           Timestamp created = r.getTimestamp(2);
-          if (expired(created)) {
+          if (expired(created.toInstant())) {
             invalidate(key);
             missCount.incrementAndGet();
             return null;
           }
 
           V val = valueSerializer.deserialize(r.getBytes(1));
-          ValueHolder<V> h = new ValueHolder<>(val);
+          ValueHolder<V> h = new ValueHolder<>(val, created.toInstant());
           h.clean = true;
           hitCount.incrementAndGet();
           touch(c, key);
@@ -429,14 +463,22 @@
       return false;
     }
 
-    private boolean expired(Timestamp created) {
+    private boolean expired(Instant created) {
       if (expireAfterWrite == null) {
         return false;
       }
-      Duration age = Duration.between(created.toInstant(), TimeUtil.now());
+      Duration age = Duration.between(created, TimeUtil.now());
       return age.compareTo(expireAfterWrite) > 0;
     }
 
+    private boolean needsRefresh(Instant created) {
+      if (refreshAfterWrite == null) {
+        return false;
+      }
+      Duration age = Duration.between(created, TimeUtil.now());
+      return age.compareTo(refreshAfterWrite) > 0;
+    }
+
     private void touch(SqlHandle c, K key) throws IOException, SQLException {
       if (c.touch == null) {
         c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=? AND version=?");
@@ -474,7 +516,7 @@
           keyType.set(c.put, 1, key);
           c.put.setBytes(2, valueSerializer.serialize(holder.value));
           c.put.setInt(3, version);
-          c.put.setTimestamp(4, new Timestamp(holder.created));
+          c.put.setTimestamp(4, Timestamp.from(holder.created));
           c.put.setTimestamp(5, TimeUtil.nowTs());
           c.put.executeUpdate();
           holder.clean = true;
@@ -560,7 +602,7 @@
             while (maxSize < used && r.next()) {
               K key = keyType.get(r, 1);
               Timestamp created = r.getTimestamp(3);
-              if (mem.getIfPresent(key) != null && !expired(created)) {
+              if (mem.getIfPresent(key) != null && !expired(created.toInstant())) {
                 touch(c, key);
               } else {
                 invalidate(c, key);
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index afed2f7..d792a39 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -123,6 +123,21 @@
       builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
     }
 
+    Duration refreshAfterWrite = def.refreshAfterWrite();
+    if (has(def.configKey(), "refreshAfterWrite")) {
+      builder.refreshAfterWrite(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              def.configKey(),
+              "refreshAfterWrite",
+              toSeconds(refreshAfterWrite),
+              SECONDS),
+          SECONDS);
+    } else if (refreshAfterWrite != null) {
+      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
+    }
+
     return builder;
   }
 
@@ -163,6 +178,21 @@
       builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
     }
 
+    Duration refreshAfterWrite = def.refreshAfterWrite();
+    if (has(def.configKey(), "refreshAfterWrite")) {
+      builder.expireAfterAccess(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              def.configKey(),
+              "refreshAfterWrite",
+              toSeconds(refreshAfterWrite),
+              SECONDS),
+          SECONDS);
+    } else if (refreshAfterWrite != null) {
+      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
+    }
+
     return builder;
   }
 
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java
new file mode 100644
index 0000000..f572c62
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class AccessSectionSerializer {
+  public static AccessSection deserialize(Cache.AccessSectionProto proto) {
+    AccessSection.Builder builder = AccessSection.builder(proto.getName());
+    proto.getPermissionsList().stream()
+        .map(PermissionSerializer::deserialize)
+        .map(Permission::toBuilder)
+        .forEach(p -> builder.addPermission(p));
+    return builder.build();
+  }
+
+  public static Cache.AccessSectionProto serialize(AccessSection autoValue) {
+    return Cache.AccessSectionProto.newBuilder()
+        .setName(autoValue.getName())
+        .addAllPermissions(
+            autoValue.getPermissions().stream()
+                .map(PermissionSerializer::serialize)
+                .collect(toImmutableList()))
+        .build();
+  }
+
+  private AccessSectionSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/AddressSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/AddressSerializer.java
new file mode 100644
index 0000000..cc86109
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/AddressSerializer.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class AddressSerializer {
+  public static Address deserialize(Cache.AddressProto proto) {
+    return Address.create(emptyToNull(proto.getName()), proto.getEmail());
+  }
+
+  public static Cache.AddressProto serialize(Address autoValue) {
+    return Cache.AddressProto.newBuilder()
+        .setName(nullToEmpty(autoValue.name()))
+        .setEmail(autoValue.email())
+        .build();
+  }
+
+  private AddressSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BUILD b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
new file mode 100644
index 0000000..cb8c4ae
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -0,0 +1,20 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "entities",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//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/git",
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializer.java
new file mode 100644
index 0000000..e86db74
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializer.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.cache.serialize.entities;
+
+import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class BranchOrderSectionSerializer {
+  public static BranchOrderSection deserialize(Cache.BranchOrderSectionProto proto) {
+    return BranchOrderSection.create(proto.getBranchesInOrderList());
+  }
+
+  public static Cache.BranchOrderSectionProto serialize(BranchOrderSection autoValue) {
+    return Cache.BranchOrderSectionProto.newBuilder()
+        .addAllBranchesInOrder(autoValue.order())
+        .build();
+  }
+
+  private BranchOrderSectionSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
new file mode 100644
index 0000000..40ef794
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import java.util.Optional;
+
+/** Helper to (de)serialize values for caches. */
+public class CachedProjectConfigSerializer {
+  public static CachedProjectConfig deserialize(Cache.CachedProjectConfigProto proto) {
+    CachedProjectConfig.Builder builder =
+        CachedProjectConfig.builder()
+            .setProject(ProjectSerializer.deserialize(proto.getProject()))
+            .setMaxObjectSizeLimit(proto.getMaxObjectSizeLimit())
+            .setCheckReceivedObjects(proto.getCheckReceivedObjects());
+    if (proto.hasBranchOrderSection()) {
+      builder.setBranchOrderSection(
+          Optional.of(BranchOrderSectionSerializer.deserialize(proto.getBranchOrderSection())));
+    }
+    ImmutableList<ConfiguredMimeTypes.TypeMatcher> matchers =
+        proto.getMimeTypesList().stream()
+            .map(ConfiguredMimeTypeSerializer::deserialize)
+            .collect(toImmutableList());
+    builder.setMimeTypes(ConfiguredMimeTypes.create(matchers));
+    if (!proto.getRulesId().isEmpty()) {
+      builder.setRulesId(
+          Optional.of(ObjectIdConverter.create().fromByteString(proto.getRulesId())));
+    }
+    if (!proto.getRevision().isEmpty()) {
+      builder.setRevision(
+          Optional.of(ObjectIdConverter.create().fromByteString(proto.getRevision())));
+    }
+    proto
+        .getExtensionPanelsMap()
+        .entrySet()
+        .forEach(
+            panelSection -> {
+              builder
+                  .extensionPanelSectionsBuilder()
+                  .put(
+                      panelSection.getKey(),
+                      panelSection.getValue().getSectionList().stream().collect(toImmutableList()));
+            });
+    ImmutableList<PermissionRule> accounts =
+        proto.getAccountsSectionList().stream()
+            .map(PermissionRuleSerializer::deserialize)
+            .collect(toImmutableList());
+    builder.setAccountsSection(AccountsSection.create(accounts));
+
+    proto.getGroupListList().stream()
+        .map(GroupReferenceSerializer::deserialize)
+        .forEach(builder::addGroup);
+    proto.getAccessSectionsList().stream()
+        .map(AccessSectionSerializer::deserialize)
+        .forEach(builder::addAccessSection);
+    proto.getContributorAgreementsList().stream()
+        .map(ContributorAgreementSerializer::deserialize)
+        .forEach(builder::addContributorAgreement);
+    proto.getNotifyConfigsList().stream()
+        .map(NotifyConfigSerializer::deserialize)
+        .forEach(builder::addNotifySection);
+    proto.getLabelSectionsList().stream()
+        .map(LabelTypeSerializer::deserialize)
+        .forEach(builder::addLabelSection);
+    proto.getSubscribeSectionsList().stream()
+        .map(SubscribeSectionSerializer::deserialize)
+        .forEach(builder::addSubscribeSection);
+    proto.getCommentLinksList().stream()
+        .map(StoredCommentLinkInfoSerializer::deserialize)
+        .forEach(builder::addCommentLinkSection);
+    proto
+        .getPluginConfigsMap()
+        .entrySet()
+        .forEach(e -> builder.addPluginConfig(e.getKey(), e.getValue()));
+    proto
+        .getProjectLevelConfigsMap()
+        .entrySet()
+        .forEach(e -> builder.addProjectLevelConfig(e.getKey(), e.getValue()));
+
+    return builder.build();
+  }
+
+  public static Cache.CachedProjectConfigProto serialize(CachedProjectConfig autoValue) {
+    Cache.CachedProjectConfigProto.Builder builder =
+        Cache.CachedProjectConfigProto.newBuilder()
+            .setProject(ProjectSerializer.serialize(autoValue.getProject()))
+            .setMaxObjectSizeLimit(autoValue.getMaxObjectSizeLimit())
+            .setCheckReceivedObjects(autoValue.getCheckReceivedObjects());
+
+    if (autoValue.getBranchOrderSection().isPresent()) {
+      builder.setBranchOrderSection(
+          BranchOrderSectionSerializer.serialize(autoValue.getBranchOrderSection().get()));
+    }
+    autoValue.getMimeTypes().matchers().stream()
+        .map(ConfiguredMimeTypeSerializer::serialize)
+        .forEach(builder::addMimeTypes);
+
+    if (autoValue.getRulesId().isPresent()) {
+      builder.setRulesId(ObjectIdConverter.create().toByteString(autoValue.getRulesId().get()));
+    }
+    if (autoValue.getRevision().isPresent()) {
+      builder.setRevision(ObjectIdConverter.create().toByteString(autoValue.getRevision().get()));
+    }
+
+    autoValue
+        .getExtensionPanelSections()
+        .entrySet()
+        .forEach(
+            panelSection -> {
+              builder.putExtensionPanels(
+                  panelSection.getKey(),
+                  Cache.CachedProjectConfigProto.ExtensionPanelSectionProto.newBuilder()
+                      .addAllSection(panelSection.getValue())
+                      .build());
+            });
+    autoValue.getAccountsSection().getSameGroupVisibility().stream()
+        .map(PermissionRuleSerializer::serialize)
+        .forEach(builder::addAccountsSection);
+
+    autoValue.getGroups().values().stream()
+        .map(GroupReferenceSerializer::serialize)
+        .forEach(builder::addGroupList);
+    autoValue.getAccessSections().values().stream()
+        .map(AccessSectionSerializer::serialize)
+        .forEach(builder::addAccessSections);
+    autoValue.getContributorAgreements().values().stream()
+        .map(ContributorAgreementSerializer::serialize)
+        .forEach(builder::addContributorAgreements);
+    autoValue.getNotifySections().values().stream()
+        .map(NotifyConfigSerializer::serialize)
+        .forEach(builder::addNotifyConfigs);
+    autoValue.getLabelSections().values().stream()
+        .map(LabelTypeSerializer::serialize)
+        .forEach(builder::addLabelSections);
+    autoValue.getSubscribeSections().values().stream()
+        .map(SubscribeSectionSerializer::serialize)
+        .forEach(builder::addSubscribeSections);
+    autoValue.getCommentLinkSections().values().stream()
+        .map(StoredCommentLinkInfoSerializer::serialize)
+        .forEach(builder::addCommentLinks);
+    builder.putAllPluginConfigs(autoValue.getPluginConfigs());
+    builder.putAllProjectLevelConfigs(autoValue.getProjectLevelConfigs());
+
+    return builder.build();
+  }
+
+  private CachedProjectConfigSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializer.java
new file mode 100644
index 0000000..6e0c923
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializer.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.server.cache.serialize.entities;
+
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.server.cache.proto.Cache;
+import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.InvalidPatternException;
+
+public class ConfiguredMimeTypeSerializer {
+  public static ConfiguredMimeTypes.TypeMatcher deserialize(Cache.ConfiguredMimeTypeProto proto) {
+    try {
+      return proto.getIsRegularExpression()
+          ? new ConfiguredMimeTypes.ReType(proto.getType(), proto.getPattern())
+          : new ConfiguredMimeTypes.FnType(proto.getType(), proto.getPattern());
+    } catch (PatternSyntaxException | InvalidPatternException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  public static Cache.ConfiguredMimeTypeProto serialize(ConfiguredMimeTypes.TypeMatcher value) {
+    return Cache.ConfiguredMimeTypeProto.newBuilder()
+        .setType(value.getType())
+        .setPattern(value.getPattern())
+        .setIsRegularExpression(value instanceof ConfiguredMimeTypes.ReType)
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java
new file mode 100644
index 0000000..19edf4f
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class ContributorAgreementSerializer {
+  public static ContributorAgreement deserialize(Cache.ContributorAgreementProto proto) {
+    ContributorAgreement.Builder builder =
+        ContributorAgreement.builder(proto.getName())
+            .setDescription(emptyToNull(proto.getDescription()))
+            .setAccepted(
+                proto.getAcceptedList().stream()
+                    .map(PermissionRuleSerializer::deserialize)
+                    .collect(toImmutableList()))
+            .setAgreementUrl(emptyToNull(proto.getUrl()))
+            .setExcludeProjectsRegexes(proto.getExcludeRegularExpressionsList())
+            .setMatchProjectsRegexes(proto.getMatchRegularExpressionsList());
+    if (proto.hasAutoVerify()) {
+      builder.setAutoVerify(GroupReferenceSerializer.deserialize(proto.getAutoVerify()));
+    }
+    return builder.build();
+  }
+
+  public static Cache.ContributorAgreementProto serialize(ContributorAgreement autoValue) {
+    Cache.ContributorAgreementProto.Builder builder =
+        Cache.ContributorAgreementProto.newBuilder()
+            .setName(autoValue.getName())
+            .setDescription(nullToEmpty(autoValue.getDescription()))
+            .addAllAccepted(
+                autoValue.getAccepted().stream()
+                    .map(PermissionRuleSerializer::serialize)
+                    .collect(toImmutableList()))
+            .setUrl(nullToEmpty(autoValue.getAgreementUrl()))
+            .addAllExcludeRegularExpressions(autoValue.getExcludeProjectsRegexes())
+            .addAllMatchRegularExpressions(autoValue.getMatchProjectsRegexes());
+    if (autoValue.getAutoVerify() != null) {
+      builder.setAutoVerify(GroupReferenceSerializer.serialize(autoValue.getAutoVerify()));
+    }
+    return builder.build();
+  }
+
+  private ContributorAgreementSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializer.java
new file mode 100644
index 0000000..c5d4d07
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializer.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class GroupReferenceSerializer {
+  public static GroupReference deserialize(Cache.GroupReferenceProto proto) {
+    if (!proto.getUuid().isEmpty()) {
+      return GroupReference.create(AccountGroup.uuid(proto.getUuid()), proto.getName());
+    }
+    return GroupReference.create(proto.getName());
+  }
+
+  public static Cache.GroupReferenceProto serialize(GroupReference autoValue) {
+    return Cache.GroupReferenceProto.newBuilder()
+        .setName(autoValue.getName())
+        .setUuid(autoValue.getUUID() == null ? "" : autoValue.getUUID().get())
+        .build();
+  }
+
+  private GroupReferenceSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
new file mode 100644
index 0000000..291db4a
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class LabelTypeSerializer {
+  private static final Converter<String, LabelFunction> FUNCTION_CONVERTER =
+      Enums.stringConverter(LabelFunction.class);
+
+  public static LabelType deserialize(Cache.LabelTypeProto proto) {
+    return LabelType.builder(
+            proto.getName(),
+            proto.getValuesList().stream()
+                .map(LabelValueSerializer::deserialize)
+                .collect(toImmutableList()))
+        .setFunction(FUNCTION_CONVERTER.convert(proto.getFunction()))
+        .setAllowPostSubmit(proto.getAllowPostSubmit())
+        .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
+        .setDefaultValue(Shorts.saturatedCast(proto.getDefaultValue()))
+        .setCopyAnyScore(proto.getCopyAnyScore())
+        .setCopyMinScore(proto.getCopyMinScore())
+        .setCopyMaxScore(proto.getCopyMaxScore())
+        .setCopyAllScoresOnMergeFirstParentUpdate(proto.getCopyAllScoresOnMergeFirstParentUpdate())
+        .setCopyAllScoresOnTrivialRebase(proto.getCopyAllScoresOnTrivialRebase())
+        .setCopyAllScoresIfNoCodeChange(proto.getCopyAllScoresIfNoCodeChange())
+        .setCopyAllScoresIfNoChange(proto.getCopyAllScoresIfNoChange())
+        .setCopyValues(
+            proto.getCopyValuesList().stream()
+                .map(Shorts::saturatedCast)
+                .collect(toImmutableList()))
+        .setMaxNegative(Shorts.saturatedCast(proto.getMaxNegative()))
+        .setMaxPositive(Shorts.saturatedCast(proto.getMaxPositive()))
+        .setRefPatterns(proto.getRefPatternsList())
+        .setCanOverride(proto.getCanOverride())
+        .build();
+  }
+
+  public static Cache.LabelTypeProto serialize(LabelType autoValue) {
+    return Cache.LabelTypeProto.newBuilder()
+        .setName(autoValue.getName())
+        .addAllValues(
+            autoValue.getValues().stream()
+                .map(LabelValueSerializer::serialize)
+                .collect(toImmutableList()))
+        .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
+        .setCopyAnyScore(autoValue.isCopyAnyScore())
+        .setCopyMinScore(autoValue.isCopyMinScore())
+        .setCopyMaxScore(autoValue.isCopyMaxScore())
+        .setCopyAllScoresOnMergeFirstParentUpdate(
+            autoValue.isCopyAllScoresOnMergeFirstParentUpdate())
+        .setCopyAllScoresOnTrivialRebase(autoValue.isCopyAllScoresOnTrivialRebase())
+        .setCopyAllScoresIfNoCodeChange(autoValue.isCopyAllScoresIfNoCodeChange())
+        .setCopyAllScoresIfNoChange(autoValue.isCopyAllScoresIfNoChange())
+        .addAllCopyValues(
+            autoValue.getCopyValues().stream().map(c -> (int) c).collect(toImmutableList()))
+        .setAllowPostSubmit(autoValue.isAllowPostSubmit())
+        .setIgnoreSelfApproval(autoValue.isIgnoreSelfApproval())
+        .setDefaultValue(Shorts.saturatedCast(autoValue.getDefaultValue()))
+        .setMaxNegative(Shorts.saturatedCast(autoValue.getMaxNegative()))
+        .setMaxPositive(Shorts.saturatedCast(autoValue.getMaxPositive()))
+        .addAllRefPatterns(
+            autoValue.getRefPatterns() == null ? ImmutableList.of() : autoValue.getRefPatterns())
+        .setCanOverride(autoValue.isCanOverride())
+        .build();
+  }
+
+  private LabelTypeSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializer.java
new file mode 100644
index 0000000..c1ca9a1
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializer.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.cache.serialize.entities;
+
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class LabelValueSerializer {
+  public static LabelValue deserialize(Cache.LabelValueProto proto) {
+    return LabelValue.create(Shorts.saturatedCast(proto.getValue()), proto.getText());
+  }
+
+  public static Cache.LabelValueProto serialize(LabelValue autoValue) {
+    return Cache.LabelValueProto.newBuilder()
+        .setText(autoValue.getText())
+        .setValue(autoValue.getValue())
+        .build();
+  }
+
+  private LabelValueSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializer.java
new file mode 100644
index 0000000..f0f7d905
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializer.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.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.gerrit.entities.NotifyConfig;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class NotifyConfigSerializer {
+  private static final Converter<String, NotifyConfig.Header> HEADER_CONVERTER =
+      Enums.stringConverter(NotifyConfig.Header.class);
+
+  private static final Converter<String, NotifyConfig.NotifyType> NOTIFY_TYPE_CONVERTER =
+      Enums.stringConverter(NotifyConfig.NotifyType.class);
+
+  public static NotifyConfig deserialize(Cache.NotifyConfigProto proto) {
+    NotifyConfig.Builder builder =
+        NotifyConfig.builder()
+            .setName(emptyToNull(proto.getName()))
+            .setNotify(
+                proto.getTypeList().stream()
+                    .map(t -> NOTIFY_TYPE_CONVERTER.convert(t))
+                    .collect(toImmutableSet()))
+            .setFilter(emptyToNull(proto.getFilter()))
+            .setHeader(
+                proto.getHeader().isEmpty() ? null : HEADER_CONVERTER.convert(proto.getHeader()));
+    proto.getGroupsList().stream()
+        .map(GroupReferenceSerializer::deserialize)
+        .forEach(g -> builder.addGroup(g));
+    proto.getAddressesList().stream()
+        .map(AddressSerializer::deserialize)
+        .forEach(a -> builder.addAddress(a));
+    return builder.build();
+  }
+
+  public static Cache.NotifyConfigProto serialize(NotifyConfig autoValue) {
+    return Cache.NotifyConfigProto.newBuilder()
+        .setName(nullToEmpty(autoValue.getName()))
+        .addAllType(
+            autoValue.getNotify().stream()
+                .map(t -> NOTIFY_TYPE_CONVERTER.reverse().convert(t))
+                .collect(toImmutableSet()))
+        .setFilter(nullToEmpty(autoValue.getFilter()))
+        .setHeader(
+            autoValue.getHeader() == null
+                ? ""
+                : HEADER_CONVERTER.reverse().convert(autoValue.getHeader()))
+        .addAllGroups(
+            autoValue.getGroups().stream()
+                .map(GroupReferenceSerializer::serialize)
+                .collect(toImmutableSet()))
+        .addAllAddresses(
+            autoValue.getAddresses().stream()
+                .map(AddressSerializer::serialize)
+                .collect(toImmutableList()))
+        .build();
+  }
+
+  private NotifyConfigSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java
new file mode 100644
index 0000000..41fb85f
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class PermissionRuleSerializer {
+  private static final Converter<String, PermissionRule.Action> ACTION_CONVERTER =
+      Enums.stringConverter(PermissionRule.Action.class);
+
+  public static PermissionRule deserialize(Cache.PermissionRuleProto proto) {
+    return PermissionRule.builder(GroupReferenceSerializer.deserialize(proto.getGroup()))
+        .setAction(ACTION_CONVERTER.convert(proto.getAction()))
+        .setForce(proto.getForce())
+        .setMin(proto.getMin())
+        .setMax(proto.getMax())
+        .build();
+  }
+
+  public static Cache.PermissionRuleProto serialize(PermissionRule autoValue) {
+    return Cache.PermissionRuleProto.newBuilder()
+        .setAction(ACTION_CONVERTER.reverse().convert(autoValue.getAction()))
+        .setForce(autoValue.getForce())
+        .setMin(autoValue.getMin())
+        .setMax(autoValue.getMax())
+        .setGroup(GroupReferenceSerializer.serialize(autoValue.getGroup()))
+        .build();
+  }
+
+  private PermissionRuleSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java
new file mode 100644
index 0000000..01d3393
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class PermissionSerializer {
+  public static Permission deserialize(Cache.PermissionProto proto) {
+    Permission.Builder builder =
+        Permission.builder(proto.getName()).setExclusiveGroup(proto.getExclusiveGroup());
+    proto.getRulesList().stream()
+        .map(PermissionRuleSerializer::deserialize)
+        .map(PermissionRule::toBuilder)
+        .forEach(rule -> builder.add(rule));
+    return builder.build();
+  }
+
+  public static Cache.PermissionProto serialize(Permission autoValue) {
+    return Cache.PermissionProto.newBuilder()
+        .setName(autoValue.getName())
+        .setExclusiveGroup(autoValue.getExclusiveGroup())
+        .addAllRules(
+            autoValue.getRules().stream()
+                .map(PermissionRuleSerializer::serialize)
+                .collect(toImmutableList()))
+        .build();
+  }
+
+  private PermissionSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java
new file mode 100644
index 0000000..aa1f4ce
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.server.cache.proto.Cache;
+import java.util.Arrays;
+import java.util.Set;
+
+/** Helper to (de)serialize values for caches. */
+public class ProjectSerializer {
+  private static final Converter<String, ProjectState> PROJECT_STATE_CONVERTER =
+      Enums.stringConverter(ProjectState.class);
+  private static final Converter<String, SubmitType> SUBMIT_TYPE_CONVERTER =
+      Enums.stringConverter(SubmitType.class);
+
+  public static Project deserialize(Cache.ProjectProto proto) {
+    Project.Builder builder =
+        Project.builder(Project.nameKey(proto.getName()))
+            .setSubmitType(SUBMIT_TYPE_CONVERTER.convert(proto.getSubmitType()))
+            .setState(PROJECT_STATE_CONVERTER.convert(proto.getState()))
+            .setDescription(emptyToNull(proto.getDescription()))
+            .setParent(emptyToNull(proto.getParent()))
+            .setMaxObjectSizeLimit(emptyToNull(proto.getMaxObjectSizeLimit()))
+            .setDefaultDashboard(emptyToNull(proto.getDefaultDashboard()))
+            .setLocalDefaultDashboard(emptyToNull(proto.getLocalDefaultDashboard()))
+            .setConfigRefState(emptyToNull(proto.getConfigRefState()));
+
+    Set<String> configs =
+        Arrays.stream(BooleanProjectConfig.values())
+            .map(BooleanProjectConfig::name)
+            .collect(toImmutableSet());
+    proto
+        .getBooleanConfigsMap()
+        .entrySet()
+        .forEach(
+            configEntry -> {
+              if (configs.contains(configEntry.getKey())) {
+                builder.setBooleanConfig(
+                    BooleanProjectConfig.valueOf(configEntry.getKey()),
+                    InheritableBoolean.valueOf(configEntry.getValue()));
+              }
+            });
+
+    return builder.build();
+  }
+
+  public static Cache.ProjectProto serialize(Project autoValue) {
+    Cache.ProjectProto.Builder builder =
+        Cache.ProjectProto.newBuilder()
+            .setName(autoValue.getName())
+            .setSubmitType(SUBMIT_TYPE_CONVERTER.reverse().convert(autoValue.getSubmitType()))
+            .setState(PROJECT_STATE_CONVERTER.reverse().convert(autoValue.getState()))
+            .setDescription(nullToEmpty(autoValue.getDescription()))
+            .setParent(nullToEmpty(autoValue.getParentName()))
+            .setMaxObjectSizeLimit(nullToEmpty(autoValue.getMaxObjectSizeLimit()))
+            .setDefaultDashboard(nullToEmpty(autoValue.getDefaultDashboard()))
+            .setLocalDefaultDashboard(nullToEmpty(autoValue.getLocalDefaultDashboard()))
+            .setConfigRefState(nullToEmpty(autoValue.getConfigRefState()));
+
+    autoValue
+        .getBooleanConfigs()
+        .entrySet()
+        .forEach(
+            configEntry -> {
+              builder.putBooleanConfigs(configEntry.getKey().name(), configEntry.getValue().name());
+            });
+
+    return builder.build();
+  }
+
+  private ProjectSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
new file mode 100644
index 0000000..a7a84f7
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+
+import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.server.cache.proto.Cache;
+import java.util.Optional;
+
+/** Helper to (de)serialize values for caches. */
+public class StoredCommentLinkInfoSerializer {
+  public static StoredCommentLinkInfo deserialize(Cache.StoredCommentLinkInfoProto proto) {
+    return StoredCommentLinkInfo.builder(proto.getName())
+        .setMatch(emptyToNull(proto.getMatch()))
+        .setLink(emptyToNull(proto.getLink()))
+        .setHtml(emptyToNull(proto.getHtml()))
+        .setEnabled(proto.getEnabled())
+        .setOverrideOnly(proto.getOverrideOnly())
+        .build();
+  }
+
+  public static Cache.StoredCommentLinkInfoProto serialize(StoredCommentLinkInfo autoValue) {
+    return Cache.StoredCommentLinkInfoProto.newBuilder()
+        .setName(autoValue.getName())
+        .setMatch(nullToEmpty(autoValue.getMatch()))
+        .setLink(nullToEmpty(autoValue.getLink()))
+        .setHtml(nullToEmpty(autoValue.getHtml()))
+        .setEnabled(Optional.ofNullable(autoValue.getEnabled()).orElse(true))
+        .setOverrideOnly(autoValue.getOverrideOnly())
+        .build();
+  }
+
+  private StoredCommentLinkInfoSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.java
new file mode 100644
index 0000000..2046f3a
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.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.server.cache.serialize.entities;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubscribeSection;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class SubscribeSectionSerializer {
+  public static SubscribeSection deserialize(Cache.SubscribeSectionProto proto) {
+    SubscribeSection.Builder builder =
+        SubscribeSection.builder(Project.nameKey(proto.getProjectName()));
+    proto.getMatchingRefSpecsList().forEach(rs -> builder.addMatchingRefSpec(rs));
+    proto.getMultiMatchRefSpecsList().forEach(rs -> builder.addMultiMatchRefSpec(rs));
+    return builder.build();
+  }
+
+  public static Cache.SubscribeSectionProto serialize(SubscribeSection autoValue) {
+    Cache.SubscribeSectionProto.Builder builder =
+        Cache.SubscribeSectionProto.newBuilder().setProjectName(autoValue.project().get());
+    autoValue.multiMatchRefSpecsAsString().forEach(rs -> builder.addMultiMatchRefSpecs(rs));
+    autoValue.matchingRefSpecsAsString().forEach(rs -> builder.addMatchingRefSpecs(rs));
+    return builder.build();
+  }
+
+  private SubscribeSectionSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index eb6e8d7..6c39ed0 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
 import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -42,6 +43,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final ChangeAbandoned changeAbandoned;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final String msgTxt;
   private final AccountState accountState;
@@ -61,12 +63,14 @@
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeAbandoned changeAbandoned,
+      MessageIdGenerator messageIdGenerator,
       @Assisted @Nullable AccountState accountState,
       @Assisted @Nullable String msgTxt) {
     this.abandonedSenderFactory = abandonedSenderFactory;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.changeAbandoned = changeAbandoned;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.accountState = accountState;
     this.msgTxt = Strings.nullToEmpty(msgTxt);
@@ -110,13 +114,16 @@
   public void postUpdate(Context ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
-      ReplyToChangeSender cm = abandonedSenderFactory.create(ctx.getProject(), change.getId());
+      ReplyToChangeSender emailSender =
+          abandonedSenderFactory.create(ctx.getProject(), change.getId());
       if (accountState != null) {
-        cm.setFrom(accountState.account().id());
+        emailSender.setFrom(accountState.account().id());
       }
-      cm.setChangeMessage(message.getMessage(), ctx.getWhen());
-      cm.setNotify(notify);
-      cm.send();
+      emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+      emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
index 2778bdd..4a3f638 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -19,12 +19,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 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.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -37,13 +38,16 @@
 
   private final AddReviewerSender.Factory addReviewerSenderFactory;
   private final ExecutorService sendEmailsExecutor;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   AddReviewersEmail(
       AddReviewerSender.Factory addReviewerSenderFactory,
-      @SendEmailExecutor ExecutorService sendEmailsExecutor) {
+      @SendEmailExecutor ExecutorService sendEmailsExecutor,
+      MessageIdGenerator messageIdGenerator) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
     this.sendEmailsExecutor = sendEmailsExecutor;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   public void emailReviewersAsync(
@@ -79,14 +83,18 @@
         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();
+                AddReviewerSender emailSender =
+                    addReviewerSenderFactory.create(projectNameKey, cId);
+                emailSender.setNotify(notify);
+                emailSender.setFrom(userId);
+                emailSender.addReviewers(immutableToMail);
+                emailSender.addReviewersByEmail(immutableAddedByEmail);
+                emailSender.addExtraCC(immutableToCopy);
+                emailSender.addExtraCCByEmail(immutableCopiedByEmail);
+                emailSender.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(
+                        change.getProject(), change.currentPatchSetId()));
+                emailSender.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 7b87a29..ff8e5c6 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -29,12 +29,12 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index 829c290..8053b30 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -14,72 +14,109 @@
 
 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.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
-import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 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.gerrit.server.update.Context;
+import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Map;
-import java.util.function.Function;
+import java.io.IOException;
 
 /** Add a specified user to the attention set. */
 public class AddToAttentionSetOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    AddToAttentionSetOp create(Account.Id attentionUserId, String reason);
+    AddToAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final ChangeMessagesUtil cmUtil;
+  private final MessageIdGenerator messageIdGenerator;
+  private final AddToAttentionSetSender.Factory addToAttentionSetSender;
+  private final AttentionSetEmail.Factory attentionSetEmailFactory;
+
   private final Account.Id attentionUserId;
   private final String reason;
 
+  private Change change;
+  private boolean notify;
+
+  /**
+   * Add a specified user to the attention set.
+   *
+   * @param attentionUserId the id of the user we want to add to the attention set.
+   * @param reason the reason for adding that user.
+   * @param notify whether or not to send emails if the operation is successful.
+   */
   @Inject
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      ChangeMessagesUtil cmUtil,
+      AddToAttentionSetSender.Factory addToAttentionSetSender,
+      MessageIdGenerator messageIdGenerator,
+      AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
-      @Assisted String reason) {
+      @Assisted String reason,
+      @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.cmUtil = cmUtil;
+    this.addToAttentionSetSender = addToAttentionSetSender;
+    this.messageIdGenerator = messageIdGenerator;
+    this.attentionSetEmailFactory = attentionSetEmailFactory;
+
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
+    this.notify = notify;
   }
 
   @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;
+    if (changeData.attentionSet().stream()
+        .anyMatch(
+            u ->
+                u.account().equals(attentionUserId)
+                    && u.operation() == AttentionSetUpdate.Operation.ADD)) {
+      // We still need to perform this update to ensure that we don't remove the user in a follow-up
+      // operation, but no need to send an email about it.
+      notify = false;
     }
 
+    change = ctx.getChange();
+
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.setAttentionSetUpdates(
-        ImmutableSet.of(
-            AttentionSetUpdate.createForWrite(
-                attentionUserId, AttentionSetUpdate.Operation.ADD, reason)));
-    addMessage(ctx, update);
+    update.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(
+            attentionUserId, AttentionSetUpdate.Operation.ADD, reason));
     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));
+  @Override
+  public void postUpdate(Context ctx) {
+    if (!notify) {
+      return;
+    }
+    try {
+      attentionSetEmailFactory
+          .create(
+              addToAttentionSetSender.create(ctx.getProject(), change.getId()),
+              ctx,
+              change,
+              reason,
+              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
+              attentionUserId)
+          .sendAsync();
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.java b/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.java
new file mode 100644
index 0000000..8f8a57c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.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.server.change;
+
+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;
+
+/**
+ * Ensures that the attention set will not be changed, thus blocks {@link RemoveFromAttentionSetOp}
+ * and {@link AddToAttentionSetOp} and updates in {@link ChangeUpdate}.
+ */
+public class AttentionSetUnchangedOp implements BatchUpdateOp {
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    update.ignoreFurtherAttentionSetUpdates();
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
index 95355cf..663d7aa 100644
--- a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
+++ b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
@@ -32,6 +32,7 @@
  * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
  * developer documentation for more details and examples.
  */
+@Deprecated
 public interface ChangeAttributeFactory {
 
   /**
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index bbb94ea..6091091 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -28,30 +28,32 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 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.registration.DynamicItem;
 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;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
@@ -59,6 +61,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -108,6 +111,8 @@
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final ReviewerAdder reviewerAdder;
+  private final MessageIdGenerator messageIdGenerator;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -156,6 +161,8 @@
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
       ReviewerAdder reviewerAdder,
+      MessageIdGenerator messageIdGenerator,
+      DynamicItem<UrlFormatter> urlFormatter,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -171,6 +178,8 @@
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
     this.reviewerAdder = reviewerAdder;
+    this.messageIdGenerator = messageIdGenerator;
+    this.urlFormatter = urlFormatter;
 
     this.changeId = changeId;
     this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -187,7 +196,7 @@
   public Change createChange(Context ctx) throws IOException {
     change =
         new Change(
-            getChangeKey(ctx.getRevWalk(), commitId),
+            getChangeKey(ctx.getRevWalk()),
             changeId,
             ctx.getAccountId(),
             BranchNameKey.create(ctx.getProject(), refName),
@@ -202,10 +211,10 @@
     return change;
   }
 
-  private static Change.Key getChangeKey(RevWalk rw, ObjectId id) throws IOException {
-    RevCommit commit = rw.parseCommit(id);
+  private Change.Key getChangeKey(RevWalk rw) throws IOException {
+    RevCommit commit = rw.parseCommit(commitId);
     rw.parseBody(commit);
-    List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
+    List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
     if (!idList.isEmpty()) {
       return Change.key(idList.get(idList.size() - 1).trim());
     }
@@ -461,21 +470,24 @@
             @Override
             public void run() {
               try {
-                CreateChangeSender cm =
+                CreateChangeSender emailSender =
                     createChangeSenderFactory.create(change.getProject(), change.getId());
-                cm.setFrom(change.getOwner());
-                cm.setPatchSet(patchSet, patchSetInfo);
-                cm.setNotify(notify);
-                cm.addReviewers(
+                emailSender.setFrom(change.getOwner());
+                emailSender.setPatchSet(patchSet, patchSetInfo);
+                emailSender.setNotify(notify);
+                emailSender.addReviewers(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
-                cm.addReviewersByEmail(
+                emailSender.addReviewersByEmail(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
-                cm.addExtraCC(reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
-                cm.addExtraCCByEmail(
+                emailSender.addExtraCC(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
+                emailSender.addExtraCCByEmail(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
-                cm.send();
+                emailSender.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+                emailSender.send();
               } catch (Exception e) {
                 logger.atSevere().withCause(e).log(
                     "Cannot send email for new change %s", change.getId());
@@ -534,6 +546,7 @@
               cmd,
               projectState.getProject(),
               change.getDest().branch(),
+              ctx.getRepoView().getConfig(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
               ctx.getIdentifiedUser())) {
@@ -577,13 +590,15 @@
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getAuthor().getAccount(),
-                    NotifyHandling.NONE)),
+                    NotifyHandling.NONE,
+                    change.getOwner())),
             Streams.stream(
                 newAddReviewerInputFromCommitIdentity(
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getCommitter().getAccount(),
-                    NotifyHandling.NONE)))
+                    NotifyHandling.NONE,
+                    change.getOwner())))
         .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 8c4f275..7b2663a 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -39,6 +39,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
@@ -46,26 +47,28 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRecord.Status;
-import com.google.gerrit.common.data.SubmitRequirement;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Status;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 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.AttentionSetInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
@@ -73,7 +76,6 @@
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
@@ -140,7 +142,6 @@
           COMMIT_FOOTERS,
           CURRENT_ACTIONS,
           CURRENT_COMMIT,
-          DETAILED_LABELS, // may need to load ChangeNotes to check remove reviewer permissions
           MESSAGES);
 
   @Singleton
@@ -157,13 +158,17 @@
     }
 
     public ChangeJson create(Iterable<ListChangesOption> options) {
-      return factory.create(options, Optional.empty());
+      return factory.create(options, Optional.empty(), Optional.empty());
     }
 
     public ChangeJson create(
         Iterable<ListChangesOption> options,
-        PluginDefinedAttributesFactory pluginDefinedAttributesFactory) {
-      return factory.create(options, Optional.of(pluginDefinedAttributesFactory));
+        PluginDefinedAttributesFactory pluginDefinedAttributesFactory,
+        PluginDefinedInfosFactory pluginDefinedInfosFactory) {
+      return factory.create(
+          options,
+          Optional.of(pluginDefinedAttributesFactory),
+          Optional.of(pluginDefinedInfosFactory));
     }
 
     public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
@@ -174,7 +179,8 @@
   public interface AssistedFactory {
     ChangeJson create(
         Iterable<ListChangesOption> options,
-        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory);
+        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
+        Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory);
   }
 
   @Singleton
@@ -221,6 +227,7 @@
   private final Metrics metrics;
   private final RevisionJson revisionJson;
   private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
+  private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
 
@@ -244,7 +251,8 @@
       RevisionJson.Factory revisionJsonFactory,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
-      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory) {
+      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
+      @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
     this.userProvider = user;
     this.changeDataFactory = cdf;
     this.permissionBackend = permissionBackend;
@@ -262,6 +270,7 @@
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
+    this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
   }
@@ -280,12 +289,12 @@
   }
 
   public ChangeInfo format(ChangeData cd) {
-    return format(cd, Optional.empty(), true);
+    return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
 
   public ChangeInfo format(RevisionResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().id()), true);
+    return format(cd, Optional.of(rsrc.getPatchSet().id()), true, getPluginInfos(cd));
   }
 
   public List<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
@@ -294,8 +303,10 @@
       accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
       List<List<ChangeInfo>> res = new ArrayList<>(in.size());
       Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
+      ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
+          getPluginInfos(in.stream().flatMap(e -> e.entities().stream()).collect(toList()));
       for (QueryResult<ChangeData> r : in) {
-        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
+        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache, pluginInfosByChange);
         if (!infos.isEmpty() && r.more()) {
           infos.get(infos.size() - 1)._moreChanges = true;
         }
@@ -310,8 +321,9 @@
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     ensureLoaded(in);
     List<ChangeInfo> out = new ArrayList<>(in.size());
+    ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange = getPluginInfos(in);
     for (ChangeData cd : in) {
-      out.add(format(cd, Optional.empty(), false));
+      out.add(format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId())));
     }
     accountLoader.fill();
     return out;
@@ -327,7 +339,8 @@
       }
       return checkOnly(changeDataFactory.create(project, id));
     }
-    return format(changeDataFactory.create(notes), Optional.empty(), true);
+    ChangeData cd = changeDataFactory.create(notes);
+    return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
 
   private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
@@ -359,15 +372,18 @@
   }
 
   private ChangeInfo format(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader) {
+      ChangeData cd,
+      Optional<PatchSet.Id> limitToPsId,
+      boolean fillAccountLoader,
+      List<PluginDefinedInfo> pluginInfosForChange) {
     try {
       if (fillAccountLoader) {
         accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-        ChangeInfo res = toChangeInfo(cd, limitToPsId);
+        ChangeInfo res = toChangeInfo(cd, limitToPsId, pluginInfosForChange);
         accountLoader.fill();
         return res;
       }
-      return toChangeInfo(cd, limitToPsId);
+      return toChangeInfo(cd, limitToPsId, pluginInfosForChange);
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
@@ -405,7 +421,9 @@
   }
 
   private List<ChangeInfo> toChangeInfos(
-      List<ChangeData> changes, Map<Change.Id, ChangeInfo> cache) {
+      List<ChangeData> changes,
+      Map<Change.Id, ChangeInfo> cache,
+      ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange) {
     try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
       List<ChangeInfo> changeInfos = new ArrayList<>(changes.size());
       for (int i = 0; i < changes.size(); i++) {
@@ -426,7 +444,7 @@
         // Compute and cache if possible
         try {
           ensureLoaded(Collections.singleton(cd));
-          info = format(cd, Optional.empty(), false);
+          info = format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()));
           changeInfos.add(info);
           if (isCacheable) {
             cache.put(Change.id(info._number), info);
@@ -481,14 +499,18 @@
     return info;
   }
 
-  private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+  private ChangeInfo toChangeInfo(
+      ChangeData cd,
+      Optional<PatchSet.Id> limitToPsId,
+      List<PluginDefinedInfo> pluginInfosForChange)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
-      return toChangeInfoImpl(cd, limitToPsId);
+      return toChangeInfoImpl(cd, limitToPsId, pluginInfosForChange);
     }
   }
 
-  private ChangeInfo toChangeInfoImpl(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+  private ChangeInfo toChangeInfoImpl(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, List<PluginDefinedInfo> pluginInfos)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
@@ -516,7 +538,7 @@
                   toImmutableMap(
                       a -> a.account().get(),
                       a ->
-                          new AttentionSetEntry(
+                          new AttentionSetInfo(
                               accountLoader.get(a.account()),
                               Timestamp.from(a.timestamp()),
                               a.reason())));
@@ -580,7 +602,9 @@
                 ? labelsJson.permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
       }
+    }
 
+    if (has(LABELS) || has(DETAILED_LABELS)) {
       out.reviewers = reviewerMap(cd.reviewers(), cd.reviewersByEmail(), false);
       out.pendingReviewers = reviewerMap(cd.pendingReviewers(), cd.pendingReviewersByEmail(), true);
       out.removableReviewers = removableReviewers(cd, out);
@@ -590,6 +614,15 @@
     if (pluginDefinedAttributesFactory.isPresent()) {
       out.plugins = pluginDefinedAttributesFactory.get().create(cd);
     }
+
+    if (!pluginInfos.isEmpty()) {
+      if (out.plugins == null) {
+        out.plugins = pluginInfos;
+      } else {
+        out.plugins = new ArrayList<>(out.plugins);
+        out.plugins.addAll(pluginInfos);
+      }
+    }
     out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
     out.submissionId = cd.change().getSubmissionId();
     out.cherryPickOfChange =
@@ -722,7 +755,10 @@
     // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
     // permission checks.
     boolean canRemoveAnyReviewer =
-        permissionBackendForChange(userProvider.get(), cd).test(ChangePermission.REMOVE_REVIEWER);
+        permissionBackend
+            .user(userProvider.get())
+            .change(cd)
+            .test(ChangePermission.REMOVE_REVIEWER);
     for (LabelInfo label : labels) {
       if (label.all == null) {
         continue;
@@ -818,15 +854,15 @@
     return map;
   }
 
-  /**
-   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
-   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
-   *     lazyload}.
-   */
-  private PermissionBackend.ForChange permissionBackendForChange(CurrentUser user, ChangeData cd) {
-    PermissionBackend.WithUser withUser = permissionBackend.user(user);
-    return lazyLoad
-        ? withUser.change(cd)
-        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
+  private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
+    return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
+  }
+
+  private ImmutableListMultimap<Change.Id, PluginDefinedInfo> getPluginInfos(
+      Collection<ChangeData> cds) {
+    if (pluginDefinedInfosFactory.isPresent()) {
+      return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+    }
+    return ImmutableListMultimap.of();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java b/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java
new file mode 100644
index 0000000..c6ceb61
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.DynamicOptions.BeanProvider;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Interface for plugins to provide additional fields in {@link
+ * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
+ *
+ * <p>Register a {@code ChangePluginDefinedInfoFactory} in a plugin {@code Module} like this:
+ *
+ * <pre>
+ * DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class).to(YourClass.class);
+ * </pre>
+ *
+ * <p>See the <a
+ * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">
+ * plugin developer documentation for more details and examples.
+ */
+public interface ChangePluginDefinedInfoFactory {
+
+  /**
+   * Create a plugin-provided info field for each of the provided {@link ChangeData}s.
+   *
+   * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
+   *
+   * @param cds changes.
+   * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
+   * @param plugin plugin name.
+   * @return map of the plugin's special info for each change
+   */
+  Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds, BeanProvider beanProvider, String plugin);
+}
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index a7fc5de..5a1798d 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -20,6 +20,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
@@ -188,7 +189,7 @@
     Iterable<ProjectState> projectStateTree =
         projectCache.get(getProject()).orElseThrow(illegalState(getProject())).tree();
     for (ProjectState p : projectStateTree) {
-      hashObjectId(h, p.getConfig().getRevision(), buf);
+      hashObjectId(h, p.getConfig().getRevision().orElse(null), buf);
     }
 
     changeETagComputation.runEach(
@@ -218,7 +219,7 @@
     }
   }
 
-  private void hashObjectId(Hasher h, ObjectId id, byte[] buf) {
+  private void hashObjectId(Hasher h, @Nullable ObjectId id, byte[] buf) {
     MoreObjects.firstNonNull(id, ObjectId.zeroId()).copyRawTo(buf, 0);
     h.putBytes(buf);
   }
diff --git a/java/com/google/gerrit/server/change/CommentResource.java b/java/com/google/gerrit/server/change/CommentResource.java
deleted file mode 100644
index dbe7a76..0000000
--- a/java/com/google/gerrit/server/change/CommentResource.java
+++ /dev/null
@@ -1,55 +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.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.TypeLiteral;
-
-public class CommentResource implements RestResource {
-  public static final TypeLiteral<RestView<CommentResource>> COMMENT_KIND =
-      new TypeLiteral<RestView<CommentResource>>() {};
-
-  private final RevisionResource rev;
-  private final Comment comment;
-
-  public CommentResource(RevisionResource rev, Comment c) {
-    this.rev = rev;
-    this.comment = c;
-  }
-
-  public PatchSet getPatchSet() {
-    return rev.getPatchSet();
-  }
-
-  public Comment getComment() {
-    return comment;
-  }
-
-  public String getId() {
-    return comment.key.uuid;
-  }
-
-  public Account.Id getAuthorId() {
-    return comment.author.getId();
-  }
-
-  public RevisionResource getRevisionResource() {
-    return rev;
-  }
-}
diff --git a/java/com/google/gerrit/server/change/CommentThread.java b/java/com/google/gerrit/server/change/CommentThread.java
new file mode 100644
index 0000000..0265f60
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CommentThread.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.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Representation of a comment thread.
+ *
+ * <p>A comment thread consists of at least one comment.
+ *
+ * @param <T> type of comments in the thread. Can also be {@link Comment} if the thread mixes
+ *     comments of different types.
+ */
+@AutoValue
+public abstract class CommentThread<T extends Comment> {
+
+  /** Comments in the thread in exactly the order they appear in the thread. */
+  public abstract ImmutableList<T> comments();
+
+  /** Whether the whole thread is considered as unresolved. */
+  public boolean unresolved() {
+    Optional<HumanComment> lastHumanComment =
+        Streams.findLast(
+            comments().stream()
+                .filter(HumanComment.class::isInstance)
+                .map(HumanComment.class::cast));
+    // We often use false == null for boolean fields. It's also a safe fall-back if no human comment
+    // is part of the thread.
+    return lastHumanComment.map(comment -> comment.unresolved).orElse(false);
+  }
+
+  public static <T extends Comment> Builder<T> builder() {
+    return new AutoValue_CommentThread.Builder<>();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder<T extends Comment> {
+
+    public abstract Builder<T> comments(List<T> value);
+
+    public Builder<T> addComment(T comment) {
+      commentsBuilder().add(comment);
+      return this;
+    }
+
+    abstract ImmutableList.Builder<T> commentsBuilder();
+
+    abstract ImmutableList<T> comments();
+
+    abstract CommentThread<T> autoBuild();
+
+    public CommentThread<T> build() {
+      Preconditions.checkState(
+          !comments().isEmpty(), "A comment thread must contain at least one comment.");
+      return autoBuild();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/CommentThreads.java b/java/com/google/gerrit/server/change/CommentThreads.java
new file mode 100644
index 0000000..b948737
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CommentThreads.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.ImmutableSet.toImmutableSet;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Comment;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.PriorityQueue;
+import java.util.Queue;
+import java.util.function.Function;
+
+/**
+ * Identifier of comment threads.
+ *
+ * <p>Comments are ordered into threads according to their parent relationship indicated via {@link
+ * Comment#parentUuid}. It's possible that two comments refer to the same parent, which especially
+ * happens when two persons reply in parallel. If such branches exist, we merge them into a flat
+ * list taking the comment creation date ({@link Comment#writtenOn} into account (but still
+ * preserving the general parent order). Remaining ties are resolved by using the natural order of
+ * the comment UUID, which is unique.
+ *
+ * @param <T> type of comments in the threads. Can also be {@link Comment} if the threads mix
+ *     comments of different types.
+ */
+public class CommentThreads<T extends Comment> {
+
+  private final ImmutableMap<String, T> commentPerUuid;
+  private final Map<String, ImmutableSet<T>> childrenPerParent;
+
+  public CommentThreads(
+      ImmutableMap<String, T> commentPerUuid, Map<String, ImmutableSet<T>> childrenPerParent) {
+    this.commentPerUuid = commentPerUuid;
+    this.childrenPerParent = childrenPerParent;
+  }
+
+  public static <T extends Comment> CommentThreads<T> forComments(Iterable<T> comments) {
+    ImmutableMap<String, T> commentPerUuid =
+        Streams.stream(comments)
+            .distinct()
+            .collect(ImmutableMap.toImmutableMap(comment -> comment.key.uuid, Function.identity()));
+
+    Map<String, ImmutableSet<T>> childrenPerParent =
+        commentPerUuid.values().stream()
+            .filter(comment -> comment.parentUuid != null)
+            .collect(groupingBy(comment -> comment.parentUuid, toImmutableSet()));
+    return new CommentThreads<>(commentPerUuid, childrenPerParent);
+  }
+
+  /**
+   * Returns all comments organized into threads.
+   *
+   * <p>Comments appear only once.
+   */
+  public ImmutableSet<CommentThread<T>> getThreads() {
+    ImmutableSet<T> roots =
+        commentPerUuid.values().stream().filter(this::isRoot).collect(toImmutableSet());
+
+    return buildThreadsOf(roots);
+  }
+
+  /**
+   * Returns only the comment threads to which the specified comments are a reply.
+   *
+   * <p>If the specified child comments are part of the comments originally provided to {@link
+   * CommentThreads#forComments(Iterable)}, they will also appear in the returned comment threads.
+   * They don't need to be part of the originally provided comments, though, but should refer to one
+   * of these comments via their {@link Comment#parentUuid}. Child comments not referring to any
+   * known comments will be ignored.
+   *
+   * @param childComments comments for which the matching threads should be determined
+   * @return threads to which the provided child comments are a reply
+   */
+  public ImmutableSet<CommentThread<T>> getThreadsForChildren(Iterable<? extends T> childComments) {
+    ImmutableSet<T> relevantRoots =
+        Streams.stream(childComments)
+            .map(this::findRoot)
+            .filter(root -> commentPerUuid.containsKey(root.key.uuid))
+            .collect(toImmutableSet());
+    return buildThreadsOf(relevantRoots);
+  }
+
+  private T findRoot(T comment) {
+    T current = comment;
+    while (!isRoot(current)) {
+      current = commentPerUuid.get(current.parentUuid);
+    }
+    return current;
+  }
+
+  private boolean isRoot(T current) {
+    return current.parentUuid == null || !commentPerUuid.containsKey(current.parentUuid);
+  }
+
+  private ImmutableSet<CommentThread<T>> buildThreadsOf(ImmutableSet<T> roots) {
+    return roots.stream()
+        .map(root -> buildCommentThread(root, childrenPerParent))
+        .collect(toImmutableSet());
+  }
+
+  private static <T extends Comment> CommentThread<T> buildCommentThread(
+      T root, Map<String, ImmutableSet<T>> childrenPerParent) {
+    CommentThread.Builder<T> commentThread = CommentThread.builder();
+    // Expand comments gradually from the root. If there is more than one child per level, place the
+    // earlier-created child earlier in the thread. Break ties with the UUID to be deterministic.
+    Queue<T> unvisited =
+        new PriorityQueue<>(
+            Comparator.comparing((T comment) -> comment.writtenOn)
+                .thenComparing(comment -> comment.key.uuid));
+    unvisited.add(root);
+    while (!unvisited.isEmpty()) {
+      T nextComment = unvisited.remove();
+      commentThread.addComment(nextComment);
+      ImmutableSet<T> children =
+          childrenPerParent.getOrDefault(nextComment.key.uuid, ImmutableSet.of());
+      unvisited.addAll(children);
+    }
+    return commentThread.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 61616c0..7d0bda1 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -28,7 +28,6 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -38,12 +37,14 @@
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ProblemInfo.Status;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.PatchSetState;
@@ -115,6 +116,7 @@
   private final Provider<CurrentUser> user;
   private final Provider<PersonIdent> serverIdent;
   private final RetryHelper retryHelper;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   private BatchUpdate.Factory updateFactory;
   private FixInput fix;
@@ -141,7 +143,8 @@
       PatchSetInserter.Factory patchSetInserterFactory,
       PatchSetUtil psUtil,
       Provider<CurrentUser> user,
-      RetryHelper retryHelper) {
+      RetryHelper retryHelper,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.accounts = accounts;
     this.accountPatchReviewStore = accountPatchReviewStore;
     this.notesFactory = notesFactory;
@@ -152,6 +155,7 @@
     this.retryHelper = retryHelper;
     this.serverIdent = serverIdent;
     this.user = user;
+    this.urlFormatter = urlFormatter;
     reset();
   }
 
@@ -456,7 +460,8 @@
           // No patch set for this commit; insert one.
           rw.parseBody(commit);
           String changeId =
-              Iterables.getFirst(commit.getFooterLines(FooterConstants.CHANGE_ID), null);
+              Iterables.getFirst(
+                  ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get()), null);
           // Missing Change-Id footer is ok, but mismatched is not.
           if (changeId != null && !changeId.equals(change().getKey().get())) {
             problem(
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 3bc9324..255e13a 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -15,12 +15,13 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -36,6 +37,8 @@
   }
 
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final MessageIdGenerator messageIdGenerator;
+
   private final Address reviewer;
 
   private ChangeMessage changeMessage;
@@ -43,8 +46,11 @@
 
   @Inject
   DeleteReviewerByEmailOp(
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory, @Assisted Address reviewer) {
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted Address reviewer) {
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.reviewer = reviewer;
   }
 
@@ -73,13 +79,15 @@
       if (!notify.shouldNotify()) {
         return;
       }
-      DeleteReviewerSender cm =
+      DeleteReviewerSender emailSender =
           deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-      cm.setFrom(ctx.getAccountId());
-      cm.addReviewersByEmail(Collections.singleton(reviewer));
-      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-      cm.setNotify(notify);
-      cm.send();
+      emailSender.setFrom(ctx.getAccountId());
+      emailSender.addReviewersByEmail(Collections.singleton(reviewer));
+      emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+      emailSender.send();
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index b70b059..07cb04f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -18,11 +18,11 @@
 
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -45,14 +46,13 @@
 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.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 
 public class DeleteReviewerOp implements BatchUpdateOp {
@@ -71,6 +71,7 @@
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final AccountState reviewer;
   private final DeleteReviewerInput input;
@@ -92,6 +93,7 @@
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator,
       @Assisted AccountState reviewerAccount,
       @Assisted DeleteReviewerInput input) {
     this.approvalsUtil = approvalsUtil;
@@ -103,6 +105,7 @@
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
     this.reviewer = reviewerAccount;
     this.input = input;
   }
@@ -134,12 +137,10 @@
     msg.append("Removed reviewer " + reviewer.account().fullName());
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
-    List<PatchSetApproval> del = new ArrayList<>();
     boolean votesRemoved = false;
     for (PatchSetApproval a : approvals(ctx, reviewerId)) {
       // Check if removing this vote is OK
       removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-      del.add(a);
       if (a.patchSetId().equals(currPs.id()) && a.value() != 0) {
         oldApprovals.put(a.label(), a.value());
         removedVotesMsg
@@ -181,7 +182,7 @@
     }
     try {
       if (notify.shouldNotify()) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage, notify);
+        emailReviewers(ctx.getProject(), currChange, changeMessage, notify, ctx.getRepoView());
       }
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
@@ -215,18 +216,22 @@
       Project.NameKey projectName,
       Change change,
       ChangeMessage changeMessage,
-      NotifyResolver.Result notify)
+      NotifyResolver.Result notify,
+      RepoView repoView)
       throws EmailException {
     Account.Id userId = user.get().getAccountId();
     if (userId.equals(reviewer.account().id())) {
       // The user knows they removed themselves, don't bother emailing them.
       return;
     }
-    DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
-    cm.setFrom(userId);
-    cm.addReviewers(Collections.singleton(reviewer.account().id()));
-    cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-    cm.setNotify(notify);
-    cm.send();
+    DeleteReviewerSender emailSender =
+        deleteReviewerSenderFactory.create(projectName, change.getId());
+    emailSender.setFrom(userId);
+    emailSender.addReviewers(Collections.singleton(reviewer.account().id()));
+    emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+    emailSender.setNotify(notify);
+    emailSender.setMessageId(
+        messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
+    emailSender.send();
   }
 }
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index 3d3e8f9..19a495d 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.TypeLiteral;
 
 public class DraftCommentResource implements RestResource {
@@ -27,9 +28,9 @@
       new TypeLiteral<RestView<DraftCommentResource>>() {};
 
   private final RevisionResource rev;
-  private final Comment comment;
+  private final HumanComment comment;
 
-  public DraftCommentResource(RevisionResource rev, Comment c) {
+  public DraftCommentResource(RevisionResource rev, HumanComment c) {
     this.rev = rev;
     this.comment = c;
   }
@@ -46,10 +47,14 @@
     return rev.getPatchSet();
   }
 
-  public Comment getComment() {
+  public HumanComment getComment() {
     return comment;
   }
 
+  public ChangeNotes getNotes() {
+    return rev.getNotes();
+  }
+
   public String getId() {
     return comment.key.uuid;
   }
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index f7e45e7..cacfbe7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -25,8 +25,10 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -63,24 +65,27 @@
         PatchSet patchSet,
         IdentifiedUser user,
         ChangeMessage message,
-        List<Comment> comments,
+        List<? extends Comment> comments,
         String patchSetComment,
-        List<LabelVote> labels);
+        List<LabelVote> labels,
+        RepoView repoView);
   }
 
   private final ExecutorService sendEmailsExecutor;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommentSender.Factory commentSenderFactory;
   private final ThreadLocalRequestContext requestContext;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final NotifyResolver.Result notify;
   private final ChangeNotes notes;
   private final PatchSet patchSet;
   private final IdentifiedUser user;
   private final ChangeMessage message;
-  private final List<Comment> comments;
+  private final List<? extends Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
+  private final RepoView repoView;
 
   @Inject
   EmailReviewComments(
@@ -88,18 +93,21 @@
       PatchSetInfoFactory patchSetInfoFactory,
       CommentSender.Factory commentSenderFactory,
       ThreadLocalRequestContext requestContext,
+      MessageIdGenerator messageIdGenerator,
       @Assisted NotifyResolver.Result notify,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
       @Assisted ChangeMessage message,
-      @Assisted List<Comment> comments,
+      @Assisted List<? extends Comment> comments,
       @Nullable @Assisted String patchSetComment,
-      @Assisted List<LabelVote> labels) {
+      @Assisted List<LabelVote> labels,
+      @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commentSenderFactory = commentSenderFactory;
     this.requestContext = requestContext;
+    this.messageIdGenerator = messageIdGenerator;
     this.notify = notify;
     this.notes = notes;
     this.patchSet = patchSet;
@@ -108,6 +116,7 @@
     this.comments = COMMENT_ORDER.sortedCopy(comments);
     this.patchSetComment = patchSetComment;
     this.labels = labels;
+    this.repoView = repoView;
   }
 
   public void sendAsync() {
@@ -119,15 +128,19 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      CommentSender cm = commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
-      cm.setFrom(user.getAccountId());
-      cm.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      cm.setChangeMessage(message.getMessage(), message.getWrittenOn());
-      cm.setComments(comments);
-      cm.setPatchSetComment(patchSetComment);
-      cm.setLabels(labels);
-      cm.setNotify(notify);
-      cm.send();
+      CommentSender emailSender =
+          commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
+      emailSender.setFrom(user.getAccountId());
+      emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
+      emailSender.setChangeMessage(message.getMessage(), message.getWrittenOn());
+      emailSender.setComments(comments);
+      emailSender.setPatchSetComment(patchSetComment);
+      emailSender.setLabels(labels);
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdateAndReason(
+              repoView, patchSet.id(), "EmailReviewComments"));
+      emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
     } finally {
diff --git a/java/com/google/gerrit/server/change/HumanCommentResource.java b/java/com/google/gerrit/server/change/HumanCommentResource.java
new file mode 100644
index 0000000..1611aaa
--- /dev/null
+++ b/java/com/google/gerrit/server/change/HumanCommentResource.java
@@ -0,0 +1,55 @@
+// 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.change;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class HumanCommentResource implements RestResource {
+  public static final TypeLiteral<RestView<HumanCommentResource>> COMMENT_KIND =
+      new TypeLiteral<RestView<HumanCommentResource>>() {};
+
+  private final RevisionResource rev;
+  private final HumanComment comment;
+
+  public HumanCommentResource(RevisionResource rev, HumanComment c) {
+    this.rev = rev;
+    this.comment = c;
+  }
+
+  public PatchSet getPatchSet() {
+    return rev.getPatchSet();
+  }
+
+  public HumanComment getComment() {
+    return comment;
+  }
+
+  public String getId() {
+    return comment.key.uuid;
+  }
+
+  public Account.Id getAuthorId() {
+    return comment.author.getId();
+  }
+
+  public RevisionResource getRevisionResource() {
+    return rev;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 67cd0df..30343d4 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -22,10 +22,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index c6f4969..b1d154c 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -32,12 +32,12 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
@@ -122,7 +122,7 @@
       if (rec.labels != null) {
         for (SubmitRecord.Label r : rec.labels) {
           LabelType type = labelTypes.byLabel(r.label);
-          if (type != null && (!isMerged || type.allowPostSubmit())) {
+          if (type != null && (!isMerged || type.isAllowPostSubmit())) {
             toCheck.put(type.getName(), type);
           }
         }
@@ -131,7 +131,7 @@
 
     Map<String, Short> labels = null;
     Set<LabelPermission.WithValue> can =
-        permissionBackendForChange(filterApprovalsBy, cd).testLabels(toCheck.values());
+        permissionBackend.absentUser(filterApprovalsBy).change(cd).testLabels(toCheck.values());
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels == null) {
@@ -139,7 +139,7 @@
       }
       for (SubmitRecord.Label r : rec.labels) {
         LabelType type = labelTypes.byLabel(r.label);
-        if (type == null || (isMerged && !type.allowPostSubmit())) {
+        if (type == null || (isMerged && !type.isAllowPostSubmit())) {
           continue;
         }
 
@@ -452,7 +452,7 @@
 
     LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
-      PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
+      PermissionBackend.ForChange perm = permissionBackend.absentUser(accountId).change(cd);
       Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         LabelType lt = labelTypes.byLabel(e.getKey());
@@ -492,18 +492,6 @@
     }
   }
 
-  /**
-   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
-   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
-   *     lazyload}.
-   */
-  private PermissionBackend.ForChange permissionBackendForChange(Account.Id user, ChangeData cd) {
-    PermissionBackend.WithUser withUser = permissionBackend.absentUser(user);
-    return lazyLoad
-        ? withUser.change(cd)
-        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
-  }
-
   private List<SubmitRecord> submitRecords(ChangeData cd) {
     return cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
   }
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 988d178..ef06ea1 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -39,6 +39,7 @@
 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.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -79,6 +80,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
+  private final MessageIdGenerator messageIdGenerator;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -120,6 +122,7 @@
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
+      MessageIdGenerator messageIdGenerator,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -133,6 +136,7 @@
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -284,14 +288,17 @@
     if (notify.shouldNotify() && sendEmail) {
       requireNonNull(changeMessage);
       try {
-        ReplacePatchSetSender cm = replacePatchSetFactory.create(ctx.getProject(), change.getId());
-        cm.setFrom(ctx.getAccountId());
-        cm.setPatchSet(patchSet, patchSetInfo);
-        cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
-        cm.addReviewers(oldReviewers.byState(REVIEWER));
-        cm.addExtraCC(oldReviewers.byState(CC));
-        cm.setNotify(notify);
-        cm.send();
+        ReplacePatchSetSender emailSender =
+            replacePatchSetFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfo);
+        emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+        emailSender.addReviewers(oldReviewers.byState(REVIEWER));
+        emailSender.addExtraCC(oldReviewers.byState(CC));
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+        emailSender.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot send email for new patch set on change %s", change.getId());
@@ -335,6 +342,7 @@
                 .orElseThrow(illegalState(origNotes.getProjectName()))
                 .getProject(),
             origNotes.getChange().getDest().branch(),
+            ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
             ctx.getIdentifiedUser())) {
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
index 9928125..b474dab 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -18,12 +18,15 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.DynamicOptions.BeanProvider;
 import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
 import java.util.Objects;
 import java.util.stream.Stream;
 
@@ -60,5 +63,44 @@
     return pdi;
   }
 
+  public static ImmutableListMultimap<Change.Id, PluginDefinedInfo> createAll(
+      Collection<ChangeData> cds,
+      BeanProvider beanProvider,
+      Stream<Extension<ChangePluginDefinedInfoFactory>> infoFactories) {
+    ImmutableListMultimap.Builder<Change.Id, PluginDefinedInfo> pluginInfosByChangeBuilder =
+        ImmutableListMultimap.builder();
+    infoFactories.forEach(
+        e -> tryCreate(cds, beanProvider, e.getPluginName(), e.get(), pluginInfosByChangeBuilder));
+    ImmutableListMultimap<Change.Id, PluginDefinedInfo> result = pluginInfosByChangeBuilder.build();
+    return result;
+  }
+
+  private static void tryCreate(
+      Collection<ChangeData> cds,
+      BeanProvider beanProvider,
+      String plugin,
+      ChangePluginDefinedInfoFactory infoFactory,
+      ImmutableListMultimap.Builder<Change.Id, PluginDefinedInfo> pluginInfosByChangeBuilder) {
+    try {
+      infoFactory
+          .createPluginDefinedInfos(cds, beanProvider, plugin)
+          .forEach(
+              (id, pdi) -> {
+                if (pdi != null) {
+                  pdi.name = plugin;
+                  pluginInfosByChangeBuilder.put(id, pdi);
+                }
+              });
+    } catch (RuntimeException ex) {
+      /* Propagate runtime exceptions as structured API data types so that queries don't fail. */
+      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
+          "error populating attribute on changes from plugin %s", plugin);
+      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
+      errorInfo.name = plugin;
+      errorInfo.message = "Something went wrong in plugin: " + plugin;
+      cds.forEach(cd -> pluginInfosByChangeBuilder.put(cd.getId(), errorInfo));
+    }
+  }
+
   private PluginDefinedAttributesFactories() {}
 }
diff --git a/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java b/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java
new file mode 100644
index 0000000..db57e29
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.collect.ImmutableListMultimap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
+
+/**
+ * Interface to generate {@code PluginDefinedInfo}s from registered {@code
+ * ChangePluginDefinedInfoFactory}s.
+ *
+ * <p>See the <a
+ * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">
+ * plugin developer documentation for more details and examples.
+ */
+public interface PluginDefinedInfosFactory {
+
+  /**
+   * Create a plugin-provided info field from all the plugins for each of the provided {@link
+   * ChangeData}s.
+   *
+   * @param cds changes.
+   * @return map of the all plugin's special infos for each change.
+   */
+  ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds);
+}
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 07f0d78..e532409 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -14,71 +14,109 @@
 
 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.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 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.gerrit.server.update.Context;
+import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Map;
-import java.util.function.Function;
+import java.io.IOException;
+import java.util.Optional;
 
 /** Remove a specified user from the attention set. */
 public class RemoveFromAttentionSetOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason);
+    RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final ChangeMessagesUtil cmUtil;
+  private final MessageIdGenerator messageIdGenerator;
+  private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
+  private final AttentionSetEmail.Factory attentionSetEmailFactory;
+
   private final Account.Id attentionUserId;
   private final String reason;
 
+  private Change change;
+  private boolean notify;
+
+  /**
+   * Remove a specified user from the attention set.
+   *
+   * @param attentionUserId the id of the user we want to add to the attention set.
+   * @param reason the reason for adding that user.
+   * @param notify whether or not to send emails if the operation is successful.
+   */
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      ChangeMessagesUtil cmUtil,
+      MessageIdGenerator messageIdGenerator,
+      RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
+      AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
-      @Assisted String reason) {
+      @Assisted String reason,
+      @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.cmUtil = cmUtil;
+    this.messageIdGenerator = messageIdGenerator;
+    this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
+    this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
+    this.notify = notify;
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws RestApiException {
     ChangeData changeData = changeDataFactory.create(ctx.getNotes());
-    Map<Account.Id, AttentionSetUpdate> attentionMap =
+    Optional<AttentionSetUpdate> existingEntry =
         changeData.attentionSet().stream()
-            .collect(toImmutableMap(AttentionSetUpdate::account, Function.identity()));
-    AttentionSetUpdate existingEntry = attentionMap.get(attentionUserId);
-    if (existingEntry == null || existingEntry.operation() == Operation.REMOVE) {
-      return false;
+            .filter(u -> u.account().equals(attentionUserId))
+            .findAny();
+    if (!existingEntry.isPresent() || existingEntry.get().operation() == Operation.REMOVE) {
+      // We still need to perform this update to ensure that we don't add the user in a follow-up
+      // operation, but no need to send an email about it.
+      notify = false;
     }
 
+    change = ctx.getChange();
+
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.setAttentionSetUpdates(
-        ImmutableSet.of(
-            AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason)));
-    addMessage(ctx, update);
+    update.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason));
     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));
+  @Override
+  public void postUpdate(Context ctx) {
+    if (!notify) {
+      return;
+    }
+    try {
+      attentionSetEmailFactory
+          .create(
+              removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
+              ctx,
+              change,
+              reason,
+              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
+              attentionUserId)
+          .sendAsync();
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/change/ResetCherryPickOp.java b/java/com/google/gerrit/server/change/ResetCherryPickOp.java
new file mode 100644
index 0000000..d1177d4
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ResetCherryPickOp.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+
+/** Reset cherryPickOf to an empty value. */
+public class ResetCherryPickOp implements BatchUpdateOp {
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    Change change = ctx.getChange();
+    if (change.getCherryPickOf() == null) {
+      return false;
+    }
+
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    update.resetCherryPickOf();
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index d9462bf..3d986d2 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -31,12 +31,13 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -48,7 +49,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -129,8 +129,12 @@
   }
 
   public static Optional<InternalAddReviewerInput> newAddReviewerInputFromCommitIdentity(
-      Change change, ObjectId commitId, @Nullable Account.Id accountId, NotifyHandling notify) {
-    if (accountId == null || accountId.equals(change.getOwner())) {
+      Change change,
+      ObjectId commitId,
+      @Nullable Account.Id accountId,
+      NotifyHandling notify,
+      Account.Id mostRecentUploader) {
+    if (accountId == null || accountId.equals(mostRecentUploader)) {
       // If git ident couldn't be resolved to a user, or if it's not forged, do nothing.
       return Optional.empty();
     }
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 39e5f74..a3136d4a 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -14,19 +14,19 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.common.data.LabelValue.formatValue;
+import static com.google.gerrit.entities.LabelValue.formatValue;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.permissions.LabelPermission;
diff --git a/java/com/google/gerrit/server/change/ReviewerResource.java b/java/com/google/gerrit/server/change/ReviewerResource.java
index df0a03f..7a98f2b 100644
--- a/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -18,10 +18,10 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 001a532..414107f 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -31,7 +31,6 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
@@ -60,7 +59,6 @@
 import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -107,8 +105,6 @@
   private final AnonymousUser anonymous;
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
-  private final ChangeNotes.Factory notesFactory;
-  private final boolean lazyLoad;
 
   @Inject
   RevisionJson(
@@ -128,7 +124,6 @@
       ChangeKindCache changeKindCache,
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
-      ChangeNotes.Factory notesFactory,
       @Assisted Iterable<ListChangesOption> options) {
     this.userProvider = userProvider;
     this.anonymous = anonymous;
@@ -145,10 +140,8 @@
     this.changeResourceFactory = changeResourceFactory;
     this.changeKindCache = changeKindCache;
     this.permissionBackend = permissionBackend;
-    this.notesFactory = notesFactory;
     this.repoManager = repoManager;
     this.options = ImmutableSet.copyOf(options);
-    this.lazyLoad = containsAnyOf(this.options, ChangeJson.REQUIRE_LAZY_LOAD);
   }
 
   /**
@@ -346,22 +339,9 @@
     return options.contains(option);
   }
 
-  /**
-   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
-   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
-   *     lazyload}.
-   */
-  private PermissionBackend.ForChange permissionBackendForChange(
-      PermissionBackend.WithUser withUser, ChangeData cd) {
-    return lazyLoad
-        ? withUser.change(cd)
-        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
-  }
-
   private boolean isWorldReadable(ChangeData cd) throws PermissionBackendException {
     try {
-      permissionBackendForChange(permissionBackend.user(anonymous), cd)
-          .check(ChangePermission.READ);
+      permissionBackend.user(anonymous).change(cd).check(ChangePermission.READ);
     } catch (AuthException ae) {
       return false;
     }
@@ -382,9 +362,4 @@
   private RevWalk newRevWalk(@Nullable Repository repo) {
     return repo != null ? new RevWalk(repo) : null;
   }
-
-  private static boolean containsAnyOf(
-      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
-    return !Sets.intersection(toFind, set).isEmpty();
-  }
 }
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 9848150..411c9b6 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -50,6 +51,7 @@
   private final SetAssigneeSender.Factory setAssigneeSenderFactory;
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   private Change change;
   private IdentifiedUser oldAssignee;
@@ -62,6 +64,7 @@
       SetAssigneeSender.Factory setAssigneeSenderFactory,
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory userFactory,
+      MessageIdGenerator messageIdGenerator,
       @Assisted IdentifiedUser newAssignee) {
     this.cmUtil = cmUtil;
     this.validationListeners = validationListeners;
@@ -69,6 +72,7 @@
     this.setAssigneeSenderFactory = setAssigneeSenderFactory;
     this.user = user;
     this.userFactory = userFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.newAssignee = requireNonNull(newAssignee, "assignee");
   }
 
@@ -118,11 +122,13 @@
   @Override
   public void postUpdate(Context ctx) {
     try {
-      SetAssigneeSender cm =
+      SetAssigneeSender emailSender =
           setAssigneeSenderFactory.create(
               change.getProject(), change.getId(), newAssignee.getAccountId());
-      cm.setFrom(user.get().getAccountId());
-      cm.send();
+      emailSender.setFrom(user.get().getAccountId());
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+      emailSender.send();
     } catch (Exception err) {
       logger.atSevere().withCause(err).log(
           "Cannot send email to new assignee of change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 283cff8..f0ebb80 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -30,8 +31,10 @@
 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.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
@@ -131,6 +134,13 @@
         || !sendEmail) {
       return;
     }
+    RepoView repoView;
+    try {
+      repoView = ctx.getRepoView();
+    } catch (IOException ex) {
+      throw new StorageException(
+          String.format("Repository %s not found", ctx.getProject().get()), ex);
+    }
     email
         .create(
             notify,
@@ -140,7 +150,8 @@
             cmsg,
             ImmutableList.of(),
             cmsg.getMessage(),
-            ImmutableList.of())
+            ImmutableList.of(),
+            repoView)
         .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java b/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
index d6e61c4..9f6ecfb5 100644
--- a/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
+++ b/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
@@ -16,8 +16,8 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.util.RequestContext;
diff --git a/java/com/google/gerrit/server/config/AllProjectsName.java b/java/com/google/gerrit/server/config/AllProjectsName.java
index 6d5525c..3a13a58 100644
--- a/java/com/google/gerrit/server/config/AllProjectsName.java
+++ b/java/com/google/gerrit/server/config/AllProjectsName.java
@@ -14,9 +14,15 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Project;
 
-/** Special name of the project that all projects derive from. */
+/**
+ * Special name of the project that all projects derive from.
+ *
+ * <p>This class is immutable and thread safe.
+ */
+@Immutable
 public class AllProjectsName extends Project.NameKey {
   private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/server/config/AllUsersName.java b/java/com/google/gerrit/server/config/AllUsersName.java
index aa92db8..393fb6b 100644
--- a/java/com/google/gerrit/server/config/AllUsersName.java
+++ b/java/com/google/gerrit/server/config/AllUsersName.java
@@ -14,9 +14,15 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Project;
 
-/** Special name of the project in which meta data for all users is stored. */
+/**
+ * Special name of the project in which meta data for all users is stored.
+ *
+ * <p>This class is immutable and thread safe.
+ */
+@Immutable
 public class AllUsersName extends Project.NameKey {
   private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index f4dcd10..388f58a 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -20,7 +20,6 @@
 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;
@@ -52,7 +51,7 @@
   public static GeneralPreferencesInfo general(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
     try {
-      return StoredPreferences.parseGeneralPreferences(
+      return PreferencesParserUtil.parseGeneralPreferences(
           userPreferences.asConfig(), configOrNull(defaultPreferences), null);
     } catch (ConfigInvalidException e) {
       return GeneralPreferencesInfo.defaults();
@@ -62,7 +61,7 @@
   public static EditPreferencesInfo edit(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
     try {
-      return StoredPreferences.parseEditPreferences(
+      return PreferencesParserUtil.parseEditPreferences(
           userPreferences.asConfig(), configOrNull(defaultPreferences), null);
     } catch (ConfigInvalidException e) {
       return EditPreferencesInfo.defaults();
@@ -72,7 +71,7 @@
   public static DiffPreferencesInfo diff(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
     try {
-      return StoredPreferences.parseDiffPreferences(
+      return PreferencesParserUtil.parseDiffPreferences(
           userPreferences.asConfig(), configOrNull(defaultPreferences), null);
     } catch (ConfigInvalidException e) {
       return DiffPreferencesInfo.defaults();
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index cf592bf..78dd38c 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -95,6 +95,7 @@
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
+import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.auth.AuthBackend;
@@ -109,6 +110,7 @@
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.LabelsJson;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
@@ -186,9 +188,11 @@
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
+import com.google.gerrit.server.submit.ConfiguredSubscriptionGraphFactory;
 import com.google.gerrit.server.submit.GitModules;
 import com.google.gerrit.server.submit.MergeSuperSetComputation;
 import com.google.gerrit.server.submit.SubmitStrategy;
+import com.google.gerrit.server.submit.SubscriptionGraph;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.IdGenerator;
@@ -239,6 +243,7 @@
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
     install(MergeabilityCacheImpl.module());
+    install(ServiceUserClassifierImpl.module());
     install(PatchListCacheImpl.module());
     install(ProjectCacheImpl.module());
     install(SectionSortCache.module());
@@ -431,7 +436,9 @@
     DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
     DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
+    DynamicSet.setOf(binder(), ChangePluginDefinedInfoFactory.class);
 
     install(new GitwebConfig.LegacyModule(cfg));
 
@@ -450,6 +457,7 @@
     factory(VersionedAuthorizedKeys.Factory.class);
 
     bind(AccountManager.class);
+    bind(SubscriptionGraph.Factory.class).to(ConfiguredSubscriptionGraphFactory.class);
 
     bind(new TypeLiteral<List<CommentLinkInfo>>() {}).toProvider(CommentLinkProvider.class);
     DynamicSet.bind(binder(), GerritConfigListener.class).to(CommentLinkProvider.class);
diff --git a/java/com/google/gerrit/server/config/GroupSetProvider.java b/java/com/google/gerrit/server/config/GroupSetProvider.java
index 7f487e1..025946d 100644
--- a/java/com/google/gerrit/server/config/GroupSetProvider.java
+++ b/java/com/google/gerrit/server/config/GroupSetProvider.java
@@ -16,8 +16,8 @@
 
 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.GroupReference;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.util.RequestContext;
diff --git a/java/com/google/gerrit/server/config/PluginConfig.java b/java/com/google/gerrit/server/config/PluginConfig.java
index 13c5442..2b363f1 100644
--- a/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/java/com/google/gerrit/server/config/PluginConfig.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// Copyright (C) 2020 The Android Open 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,63 +14,94 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Arrays;
+import java.util.HashMap;
 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.lib.Config;
 
-public class PluginConfig {
+@AutoValue
+public abstract class PluginConfig {
   private static final String PLUGIN = "plugin";
 
-  private final String pluginName;
-  private Config cfg;
-  private final ProjectConfig projectConfig;
+  protected abstract String pluginName();
 
-  public PluginConfig(String pluginName, Config cfg) {
-    this(pluginName, cfg, null);
+  protected abstract Config cfg();
+
+  protected abstract Optional<CachedProjectConfig> projectConfig();
+
+  /** Mappings parsed from {@code groups} files. */
+  protected abstract ImmutableMap<AccountGroup.UUID, GroupReference> groupReferences();
+
+  public static PluginConfig create(
+      String pluginName, Config cfg, @Nullable CachedProjectConfig projectConfig) {
+    ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupReferences =
+        ImmutableMap.builder();
+    if (projectConfig != null) {
+      groupReferences.putAll(projectConfig.getGroups());
+    }
+    return new AutoValue_PluginConfig(
+        pluginName, copyConfig(cfg), Optional.ofNullable(projectConfig), groupReferences.build());
   }
 
-  public PluginConfig(String pluginName, Config cfg, ProjectConfig projectConfig) {
-    this.pluginName = pluginName;
-    this.cfg = cfg;
-    this.projectConfig = projectConfig;
+  public static PluginConfig createFromGerritConfig(String pluginName, Config cfg) {
+    // There is no need to make a defensive copy here because this value won't be cached.
+    // gerrit.config uses baseConfig's (a member of Config) which would also make defensive copies
+    // fail.
+    return new AutoValue_PluginConfig(pluginName, cfg, Optional.empty(), ImmutableMap.of());
   }
 
   PluginConfig withInheritance(ProjectState.Factory projectStateFactory) {
-    if (projectConfig == null) {
+    checkState(projectConfig().isPresent(), "no project config provided");
+
+    ProjectState state = projectStateFactory.create(projectConfig().get());
+    ProjectState parent = Iterables.getFirst(state.parents(), null);
+    if (parent == null) {
       return this;
     }
 
-    ProjectState state = projectStateFactory.create(projectConfig);
-    ProjectState parent = Iterables.getFirst(state.parents(), null);
-    if (parent != null) {
-      PluginConfig parentPluginConfig =
-          parent.getConfig().getPluginConfig(pluginName).withInheritance(projectStateFactory);
-      Set<String> allNames = cfg.getNames(PLUGIN, pluginName);
-      cfg = copyConfig(cfg);
-      for (String name : parentPluginConfig.cfg.getNames(PLUGIN, pluginName)) {
-        if (!allNames.contains(name)) {
-          List<String> values =
-              Arrays.asList(parentPluginConfig.cfg.getStringList(PLUGIN, pluginName, name));
-          for (String value : values) {
-            GroupReference groupRef =
-                parentPluginConfig.projectConfig.getGroup(GroupReference.extractGroupName(value));
-            if (groupRef != null) {
-              projectConfig.resolve(groupRef);
-            }
+    Map<AccountGroup.UUID, GroupReference> groupReferences = new HashMap<>();
+    groupReferences.putAll(groupReferences());
+    PluginConfig parentPluginConfig =
+        parent.getPluginConfig(pluginName()).withInheritance(projectStateFactory);
+    Set<String> allNames = cfg().getNames(PLUGIN, pluginName());
+    Config newCfg = copyConfig(cfg());
+    for (String name : parentPluginConfig.cfg().getNames(PLUGIN, pluginName())) {
+      if (!allNames.contains(name)) {
+        List<String> values =
+            Arrays.asList(parentPluginConfig.cfg().getStringList(PLUGIN, pluginName(), name));
+        for (String value : values) {
+          Optional<GroupReference> groupRef =
+              parentPluginConfig
+                  .projectConfig()
+                  .get()
+                  .getGroupByName(GroupReference.extractGroupName(value));
+          if (groupRef.isPresent()) {
+            groupReferences.putIfAbsent(groupRef.get().getUUID(), groupRef.get());
           }
-          cfg.setStringList(PLUGIN, pluginName, name, values);
         }
+        newCfg.setStringList(PLUGIN, pluginName(), name, values);
       }
     }
-    return this;
+    return new AutoValue_PluginConfig(
+        pluginName(), newCfg, projectConfig(), ImmutableMap.copyOf(groupReferences));
   }
 
   private static Config copyConfig(Config cfg) {
@@ -85,86 +116,150 @@
   }
 
   public String getString(String name) {
-    return cfg.getString(PLUGIN, pluginName, name);
+    return cfg().getString(PLUGIN, pluginName(), name);
   }
 
   public String getString(String name, String defaultValue) {
     if (defaultValue == null) {
-      return cfg.getString(PLUGIN, pluginName, name);
+      return cfg().getString(PLUGIN, pluginName(), name);
     }
-    return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name), defaultValue);
-  }
-
-  public void setString(String name, String value) {
-    if (Strings.isNullOrEmpty(value)) {
-      cfg.unset(PLUGIN, pluginName, name);
-    } else {
-      cfg.setString(PLUGIN, pluginName, name, value);
-    }
+    return MoreObjects.firstNonNull(cfg().getString(PLUGIN, pluginName(), name), defaultValue);
   }
 
   public String[] getStringList(String name) {
-    return cfg.getStringList(PLUGIN, pluginName, name);
-  }
-
-  public void setStringList(String name, List<String> values) {
-    if (values == null || values.isEmpty()) {
-      cfg.unset(PLUGIN, pluginName, name);
-    } else {
-      cfg.setStringList(PLUGIN, pluginName, name, values);
-    }
+    return cfg().getStringList(PLUGIN, pluginName(), name);
   }
 
   public int getInt(String name, int defaultValue) {
-    return cfg.getInt(PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public void setInt(String name, int value) {
-    cfg.setInt(PLUGIN, pluginName, name, value);
+    return cfg().getInt(PLUGIN, pluginName(), name, defaultValue);
   }
 
   public long getLong(String name, long defaultValue) {
-    return cfg.getLong(PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public void setLong(String name, long value) {
-    cfg.setLong(PLUGIN, pluginName, name, value);
+    return cfg().getLong(PLUGIN, pluginName(), name, defaultValue);
   }
 
   public boolean getBoolean(String name, boolean defaultValue) {
-    return cfg.getBoolean(PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public void setBoolean(String name, boolean value) {
-    cfg.setBoolean(PLUGIN, pluginName, name, value);
+    return cfg().getBoolean(PLUGIN, pluginName(), name, defaultValue);
   }
 
   public <T extends Enum<?>> T getEnum(String name, T defaultValue) {
-    return cfg.getEnum(PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public <T extends Enum<?>> void setEnum(String name, T value) {
-    cfg.setEnum(PLUGIN, pluginName, name, value);
+    return cfg().getEnum(PLUGIN, pluginName(), name, defaultValue);
   }
 
   public <T extends Enum<?>> T getEnum(T[] all, String name, T defaultValue) {
-    return cfg.getEnum(all, PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public void unset(String name) {
-    cfg.unset(PLUGIN, pluginName, name);
+    return cfg().getEnum(all, PLUGIN, pluginName(), name, defaultValue);
   }
 
   public Set<String> getNames() {
-    return cfg.getNames(PLUGIN, pluginName, true);
+    return cfg().getNames(PLUGIN, pluginName(), true);
   }
 
-  public GroupReference getGroupReference(String name) {
-    return projectConfig.getGroup(GroupReference.extractGroupName(getString(name)));
+  public Optional<GroupReference> getGroupReference(String name) {
+    String exactName = GroupReference.extractGroupName(getString(name));
+    return groupReferences().values().stream().filter(g -> g.getName().equals(exactName)).findAny();
   }
 
-  public void setGroupReference(String name, GroupReference value) {
-    GroupReference groupRef = projectConfig.resolve(value);
-    setString(name, groupRef.toConfigValue());
+  /** Mutable representation of {@link PluginConfig}. Used for updates. */
+  public static class Update {
+    private final String pluginName;
+    private Config cfg;
+    private final Optional<ProjectConfig> projectConfig;
+
+    public Update(String pluginName, Config cfg, Optional<ProjectConfig> projectConfig) {
+      this.pluginName = pluginName;
+      this.cfg = cfg;
+      this.projectConfig = projectConfig;
+    }
+
+    @VisibleForTesting
+    public static Update forTest(String pluginName, Config cfg) {
+      return new Update(pluginName, cfg, Optional.empty());
+    }
+
+    public PluginConfig asPluginConfig() {
+      return PluginConfig.create(
+          pluginName, cfg, projectConfig.map(ProjectConfig::getCacheable).orElse(null));
+    }
+
+    public String getString(String name) {
+      return cfg.getString(PLUGIN, pluginName, name);
+    }
+
+    public String getString(String name, String defaultValue) {
+      if (defaultValue == null) {
+        return cfg.getString(PLUGIN, pluginName, name);
+      }
+      return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name), defaultValue);
+    }
+
+    public String[] getStringList(String name) {
+      return cfg.getStringList(PLUGIN, pluginName, name);
+    }
+
+    public int getInt(String name, int defaultValue) {
+      return cfg.getInt(PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public long getLong(String name, long defaultValue) {
+      return cfg.getLong(PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public boolean getBoolean(String name, boolean defaultValue) {
+      return cfg.getBoolean(PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public <T extends Enum<?>> T getEnum(String name, T defaultValue) {
+      return cfg.getEnum(PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public <T extends Enum<?>> T getEnum(T[] all, String name, T defaultValue) {
+      return cfg.getEnum(all, PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public Set<String> getNames() {
+      return cfg.getNames(PLUGIN, pluginName, true);
+    }
+
+    public void setString(String name, String value) {
+      if (Strings.isNullOrEmpty(value)) {
+        cfg.unset(PLUGIN, pluginName, name);
+      } else {
+        cfg.setString(PLUGIN, pluginName, name, value);
+      }
+    }
+
+    public void setStringList(String name, List<String> values) {
+      if (values == null || values.isEmpty()) {
+        cfg.unset(PLUGIN, pluginName, name);
+      } else {
+        cfg.setStringList(PLUGIN, pluginName, name, values);
+      }
+    }
+
+    public void setInt(String name, int value) {
+      cfg.setInt(PLUGIN, pluginName, name, value);
+    }
+
+    public void setLong(String name, long value) {
+      cfg.setLong(PLUGIN, pluginName, name, value);
+    }
+
+    public void setBoolean(String name, boolean value) {
+      cfg.setBoolean(PLUGIN, pluginName, name, value);
+    }
+
+    public <T extends Enum<?>> void setEnum(String name, T value) {
+      cfg.setEnum(PLUGIN, pluginName, name, value);
+    }
+
+    public void unset(String name) {
+      cfg.unset(PLUGIN, pluginName, name);
+    }
+
+    public void setGroupReference(String name, GroupReference value) {
+      checkState(projectConfig.isPresent(), "no project config provided");
+      GroupReference groupRef = projectConfig.get().resolve(value);
+      setString(name, groupRef.toConfigValue());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 483fc0a..a9abd1e 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -111,7 +111,7 @@
       cfgSnapshot = FileSnapshot.save(configFile);
       cfg = cfgProvider.get();
     }
-    return new PluginConfig(pluginName, cfg);
+    return PluginConfig.createFromGerritConfig(pluginName, cfg);
   }
 
   /**
@@ -150,7 +150,7 @@
    * @return the plugin configuration from the 'project.config' file of the specified project
    */
   public PluginConfig getFromProjectConfig(ProjectState projectState, String pluginName) {
-    return projectState.getConfig().getPluginConfig(pluginName);
+    return projectState.getPluginConfig(pluginName);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
new file mode 100644
index 0000000..69d75be
--- /dev/null
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -0,0 +1,266 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+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.server.git.UserConfigSections;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/** Helper to read default or user preferences from Git-style config files. */
+public class PreferencesParserUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private PreferencesParserUtil() {}
+
+  /**
+   * 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 =
+        loadSection(
+            cfg,
+            UserConfigSections.GENERAL,
+            null,
+            new GeneralPreferencesInfo(),
+            defaultCfg != null
+                ? parseDefaultGeneralPreferences(defaultCfg, input)
+                : GeneralPreferencesInfo.defaults(),
+            input);
+    if (input != null) {
+      r.changeTable = input.changeTable;
+      r.my = input.my;
+    } else {
+      r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
+      r.my = parseMyMenus(cfg, defaultCfg);
+    }
+    return r;
+  }
+
+  /**
+   * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
+   * the server's default configs. These configs are then overlaid to inherit values (default ->
+   * input (if provided).
+   */
+  public static GeneralPreferencesInfo parseDefaultGeneralPreferences(
+      Config defaultCfg, GeneralPreferencesInfo input) throws ConfigInvalidException {
+    GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.GENERAL,
+        null,
+        allUserPrefs,
+        GeneralPreferencesInfo.defaults(),
+        input);
+    return updateGeneralPreferencesDefaults(allUserPrefs);
+  }
+
+  /**
+   * 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(
+        cfg,
+        UserConfigSections.DIFF,
+        null,
+        new DiffPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultDiffPreferences(defaultCfg, input)
+            : DiffPreferencesInfo.defaults(),
+        input);
+  }
+
+  /**
+   * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs. These configs are then overlaid to inherit values (default -> input
+   * (if provided).
+   */
+  public static DiffPreferencesInfo parseDefaultDiffPreferences(
+      Config defaultCfg, DiffPreferencesInfo input) throws ConfigInvalidException {
+    DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.DIFF,
+        null,
+        allUserPrefs,
+        DiffPreferencesInfo.defaults(),
+        input);
+    return updateDiffPreferencesDefaults(allUserPrefs);
+  }
+
+  /**
+   * 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(
+        cfg,
+        UserConfigSections.EDIT,
+        null,
+        new EditPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultEditPreferences(defaultCfg, input)
+            : EditPreferencesInfo.defaults(),
+        input);
+  }
+
+  /**
+   * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs. These configs are then overlaid to inherit values (default -> input
+   * (if provided).
+   */
+  public static EditPreferencesInfo parseDefaultEditPreferences(
+      Config defaultCfg, EditPreferencesInfo input) throws ConfigInvalidException {
+    EditPreferencesInfo allUserPrefs = new EditPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.EDIT,
+        null,
+        allUserPrefs,
+        EditPreferencesInfo.defaults(),
+        input);
+    return updateEditPreferencesDefaults(allUserPrefs);
+  }
+
+  private static List<String> parseChangeTableColumns(Config cfg, @Nullable Config defaultCfg) {
+    List<String> changeTable = changeTable(cfg);
+    if (changeTable == null && defaultCfg != null) {
+      changeTable = changeTable(defaultCfg);
+    }
+    return changeTable;
+  }
+
+  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
+    List<MenuItem> my = my(cfg);
+    if (my.isEmpty() && defaultCfg != null) {
+      my = my(defaultCfg);
+    }
+    if (my.isEmpty()) {
+      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));
+      my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
+      my.add(new MenuItem("Groups", "#/settings/#Groups", null));
+    }
+    return my;
+  }
+
+  private static GeneralPreferencesInfo updateGeneralPreferencesDefaults(
+      GeneralPreferencesInfo input) {
+    GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      logger.atSevere().withCause(e).log("Failed to apply default general preferences");
+      return GeneralPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static DiffPreferencesInfo updateDiffPreferencesDefaults(DiffPreferencesInfo input) {
+    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      logger.atSevere().withCause(e).log("Failed to apply default diff preferences");
+      return DiffPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static EditPreferencesInfo updateEditPreferencesDefaults(EditPreferencesInfo input) {
+    EditPreferencesInfo result = EditPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      logger.atSevere().withCause(e).log("Failed to apply default edit preferences");
+      return EditPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static List<String> changeTable(Config cfg) {
+    return Lists.newArrayList(cfg.getStringList(CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
+  }
+
+  private static List<MenuItem> my(Config cfg) {
+    List<MenuItem> my = new ArrayList<>();
+    for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
+      String url = my(cfg, subsection, KEY_URL, "#/");
+      String target = my(cfg, subsection, KEY_TARGET, url.startsWith("#") ? null : "_blank");
+      my.add(new MenuItem(subsection, url, target, my(cfg, subsection, KEY_ID, null)));
+    }
+    return my;
+  }
+
+  private static String my(Config cfg, String subsection, String key, String defaultValue) {
+    String val = cfg.getString(UserConfigSections.MY, subsection, key);
+    return !Strings.isNullOrEmpty(val) ? val : defaultValue;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index ee95c6f..5e268da 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -30,6 +30,7 @@
   public static final String HEADER_FILENAME = "GerritSiteHeader.html";
   public static final String FOOTER_FILENAME = "GerritSiteFooter.html";
   public static final String THEME_FILENAME = "gerrit-theme.html";
+  public static final String THEME_JS_FILENAME = "gerrit-theme.js";
 
   public final Path site_path;
   public final Path bin_dir;
@@ -69,6 +70,7 @@
   public final Path site_header;
   public final Path site_footer;
   public final Path site_theme; // For PolyGerrit UI only.
+  public final Path site_theme_js; // For PolyGerrit UI only.
   public final Path site_gitweb;
 
   /** {@code true} if {@link #site_path} has not been initialized. */
@@ -119,6 +121,7 @@
 
     // For PolyGerrit UI.
     site_theme = static_dir.resolve(THEME_FILENAME);
+    site_theme_js = static_dir.resolve(THEME_JS_FILENAME);
 
     boolean isNew;
     try (DirectoryStream<Path> files = Files.newDirectoryStream(site_path)) {
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index e7f4540..ea45b12 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.AbstractModule;
@@ -24,7 +28,7 @@
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Module providing the {@link ReceiveCommitsExecutor}.
+ * Module providing different executors.
  *
  * <p>This module is intended to be installed at the top level when creating a {@code sysInjector}
  * in {@code Daemon} or similar, not nested in another module. This ensures the module can be
@@ -37,7 +41,7 @@
   @Provides
   @Singleton
   @ReceiveCommitsExecutor
-  public ExecutorService createReceiveCommitsExecutor(
+  public ExecutorService provideReceiveCommitsExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize =
         config.getInt(
@@ -48,11 +52,11 @@
   @Provides
   @Singleton
   @SendEmailExecutor
-  public ExecutorService createSendEmailExecutor(
+  public ExecutorService provideSendEmailExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize = config.getInt("sendemail", null, "threadPoolSize", 1);
     if (poolSize == 0) {
-      return MoreExecutors.newDirectExecutorService();
+      return newDirectExecutorService();
     }
     return queues.createQueue(poolSize, "SendEmail", true);
   }
@@ -60,11 +64,24 @@
   @Provides
   @Singleton
   @FanOutExecutor
-  public ExecutorService createFanOutExecutor(@GerritServerConfig Config config, WorkQueue queues) {
+  public ExecutorService provideFanOutExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize = config.getInt("execution", null, "fanOutThreadPoolSize", 25);
     if (poolSize == 0) {
-      return MoreExecutors.newDirectExecutorService();
+      return newDirectExecutorService();
     }
     return queues.createQueue(poolSize, "FanOut");
   }
+
+  @Provides
+  @Singleton
+  @CacheRefreshExecutor
+  public ListeningExecutorService provideCacheRefreshExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
+    int poolSize = config.getInt("cache", null, "refreshThreadPoolSize", 2);
+    if (poolSize == 0) {
+      return newDirectExecutorService();
+    }
+    return MoreExecutors.listeningDecorator(queues.createQueue(poolSize, "CacheRefresh"));
+  }
 }
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index 6b2510e..5054da6 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -47,17 +47,20 @@
     return getWebUrl().map(url -> url + "c/" + project.get() + "/+/" + id.get());
   }
 
-  /** Returns the URL for viewing a file in a given patch set of a change. */
-  default Optional<String> getPatchFileView(Change change, int patchsetId, String filename) {
-    return getChangeViewUrl(change.getProject(), change.getId())
-        .map(url -> url + "/" + patchsetId + "/" + filename);
+  /** Returns the URL for viewing the comment tab view of a change. */
+  default Optional<String> getCommentsTabView(Change change) {
+    return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=comments");
   }
 
-  /** Returns the URL for viewing a comment in a file in a given patch set of a change. */
-  default Optional<String> getInlineCommentView(
-      Change change, int patchsetId, String filename, short side, int startLine) {
-    return getPatchFileView(change, patchsetId, filename)
-        .map(url -> url + String.format("@%s%d", side == 0 ? "a" : "", startLine));
+  /** Returns the URL for viewing the findings tab view of a change. */
+  default Optional<String> getFindingsTabView(Change change) {
+    return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=findings");
+  }
+
+  /** Returns the URL for viewing a comment in a file for a change. */
+  default Optional<String> getInlineCommentView(Change change, String uuid) {
+    return getChangeViewUrl(change.getProject(), change.getId())
+        .map(url -> url + "/comment/" + uuid);
   }
 
   /** Returns a URL pointing to the settings page. */
diff --git a/java/com/google/gerrit/server/data/BUILD b/java/com/google/gerrit/server/data/BUILD
new file mode 100644
index 0000000..c3dc672
--- /dev/null
+++ b/java/com/google/gerrit/server/data/BUILD
@@ -0,0 +1,15 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "data",
+    srcs = glob(
+        ["*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/org/apache/commons/net",
+        "//lib:gson",
+    ],
+)
diff --git a/java/com/google/gerrit/server/data/SubmitLabelAttribute.java b/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
index fec8f7f..a3890c7 100644
--- a/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.data;
 
 /**
- * Represents a {@link com.google.gerrit.common.data.SubmitRecord.Label} that does not depend on
- * Gerrit internal classes, to be serialized.
+ * Represents a {@link com.google.gerrit.entities.SubmitRecord.Label} that does not depend on Gerrit
+ * internal classes, to be serialized.
  */
 public class SubmitLabelAttribute {
   public String label;
diff --git a/java/com/google/gerrit/server/data/SubmitRecordAttribute.java b/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
index 2c3d401..e6c308e 100644
--- a/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.data;
 
+import com.google.gerrit.entities.SubmitRecord;
 import java.util.List;
 
 /**
- * Represents a {@link com.google.gerrit.common.data.SubmitRecord} that does not depend on Gerrit
- * internal classes, to be serialized.
+ * Represents a {@link SubmitRecord} that does not depend on Gerrit internal classes, to be
+ * serialized.
  */
 public class SubmitRecordAttribute {
   public String status;
diff --git a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
index 2364ec4..ed4ea8a 100644
--- a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.data;
 
+import com.google.gerrit.entities.SubmitRequirement;
+
 /**
- * Represents a {@link com.google.gerrit.common.data.SubmitRequirement} that does not depend on
- * Gerrit internal classes, to be serialized
+ * Represents a {@link SubmitRequirement} that does not depend on Gerrit internal classes, to be
+ * serialized
  */
 public class SubmitRequirementAttribute {
   public String type;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 2eb46f1..2d5e708 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,7 +21,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.vladsch.flexmark.Extension;
 import com.vladsch.flexmark.ast.Block;
 import com.vladsch.flexmark.ast.Heading;
 import com.vladsch.flexmark.ast.Node;
@@ -36,7 +35,6 @@
 import java.io.UnsupportedEncodingException;
 import java.net.URL;
 import java.nio.charset.Charset;
-import java.util.ArrayList;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.commons.lang.StringEscapeUtils;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -95,11 +93,6 @@
                 options, MarkdownFormatterHeader.HeadingExtension.create())
             .toMutable();
 
-    ArrayList<Extension> extensions = new ArrayList<>();
-    for (Extension extension : optionsExt.get(com.vladsch.flexmark.parser.Parser.EXTENSIONS)) {
-      extensions.add(extension);
-    }
-
     return optionsExt;
   }
 
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 2f04cae..bc905c2 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.common.collect.ImmutableList;
+import com.google.common.base.Charsets;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -54,8 +54,13 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.TimeZone;
+import org.eclipse.jgit.diff.DiffAlgorithm;
+import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
 import org.eclipse.jgit.dircache.InvalidPathException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -65,6 +70,9 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeAlgorithm;
+import org.eclipse.jgit.merge.MergeChunk;
+import org.eclipse.jgit.merge.MergeResult;
 import org.eclipse.jgit.merge.MergeStrategy;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -84,12 +92,12 @@
 public class ChangeEditModifier {
 
   private final TimeZone tz;
-  private final ChangeIndexer indexer;
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
   private final PatchSetUtil patchSetUtil;
   private final ProjectCache projectCache;
+  private final NoteDbEdits noteDbEdits;
 
   @Inject
   ChangeEditModifier(
@@ -100,13 +108,14 @@
       ChangeEditUtil changeEditUtil,
       PatchSetUtil patchSetUtil,
       ProjectCache projectCache) {
-    this.indexer = indexer;
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
     this.tz = gerritIdent.getTimeZone();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
+
+    noteDbEdits = new NoteDbEdits(tz, indexer, currentUser);
   }
 
   /**
@@ -116,7 +125,6 @@
    * @param notes the {@link ChangeNotes} of the change for which the change edit should be created
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if a change edit already existed for the change
-   * @throws PermissionBackendException
    */
   public void createEdit(Repository repository, ChangeNotes notes)
       throws AuthException, IOException, InvalidChangeOperationException,
@@ -131,7 +139,7 @@
 
     PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
     ObjectId patchSetCommitId = currentPatchSet.commitId();
-    createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
+    noteDbEdits.createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
   }
 
   /**
@@ -143,11 +151,9 @@
    * @throws InvalidChangeOperationException if a change edit doesn't exist for the specified
    *     change, the change edit is already based on the latest patch set, or the change represents
    *     the root commit
-   * @throws MergeConflictException if rebase fails due to merge conflicts
-   * @throws PermissionBackendException
    */
   public void rebaseEdit(Repository repository, ChangeNotes notes)
-      throws AuthException, InvalidChangeOperationException, IOException, MergeConflictException,
+      throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
     assertCanEdit(notes);
 
@@ -177,8 +183,7 @@
           "Rebase change edit against root commit not supported");
     }
 
-    Change change = changeEdit.getChange();
-    RevCommit basePatchSetCommit = lookupCommit(repository, currentPatchSet);
+    RevCommit basePatchSetCommit = NoteDbEdits.lookupCommit(repository, currentPatchSet.commitId());
     RevTree basePatchSetTree = basePatchSetCommit.getTree();
 
     ObjectId newTreeId = merge(repository, changeEdit, basePatchSetTree);
@@ -187,15 +192,8 @@
     ObjectId newEditCommitId =
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
-    String newEditRefName = getEditRefName(change, currentPatchSet);
-    updateReferenceWithNameChange(
-        repository,
-        changeEdit.getRefName(),
-        currentEditCommit,
-        newEditRefName,
-        newEditCommitId,
-        nowTimestamp);
-    reindex(change);
+    noteDbEdits.baseEditOnDifferentPatchset(
+        repository, changeEdit, currentPatchSet, currentEditCommit, newEditCommitId, nowTimestamp);
   }
 
   /**
@@ -207,51 +205,17 @@
    *     modified
    * @param newCommitMessage the new commit message
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
-   * @throws UnchangedCommitMessageException if the commit message is the same as before
-   * @throws PermissionBackendException
+   * @throws InvalidChangeOperationException if the commit message is the same as before
    * @throws BadRequestException if the commit message is malformed
    */
-  public void modifyMessage(
-      Repository repository, Project.NameKey project, ChangeNotes notes, String newCommitMessage)
-      throws AuthException, IOException, UnchangedCommitMessageException,
+  public void modifyMessage(Repository repository, ChangeNotes notes, String newCommitMessage)
+      throws AuthException, IOException, InvalidChangeOperationException,
           PermissionBackendException, BadRequestException, ResourceConflictException {
-    assertCanEdit(notes);
-    newCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(newCommitMessage);
-
-    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
-    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
-    RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
-    RevCommit baseCommit =
-        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
-
-    String currentCommitMessage = baseCommit.getFullMessage();
-    if (newCommitMessage.equals(currentCommitMessage)) {
-      throw new UnchangedCommitMessageException();
-    }
-
-    RevTree baseTree = baseCommit.getTree();
-    Timestamp nowTimestamp = TimeUtil.nowTs();
-    ObjectId newEditCommit =
-        createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
-
-    ChangeUtil.ensureChangeIdIsCorrect(
-        projectCache
-            .get(project)
-            .orElseThrow(illegalState(project))
-            .is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
-        notes.getChange().getKey().get(),
-        newCommitMessage);
-
-    if (optionalChangeEdit.isPresent()) {
-      updateEdit(
-          notes.getProjectName(),
-          repository,
-          optionalChangeEdit.get(),
-          newEditCommit,
-          nowTimestamp);
-    } else {
-      createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
-    }
+    modifyCommit(
+        repository,
+        notes,
+        new ModificationIntention.LatestCommit(),
+        CommitModification.builder().newCommitMessage(newCommitMessage).build());
   }
 
   /**
@@ -265,14 +229,19 @@
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
    * @throws InvalidChangeOperationException if the file already had the specified content
-   * @throws PermissionBackendException
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void modifyFile(
       Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
       throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
-    modifyTree(repository, notes, new ChangeFileContentModification(filePath, newContent));
+    modifyCommit(
+        repository,
+        notes,
+        new ModificationIntention.LatestCommit(),
+        CommitModification.builder()
+            .addTreeModification(new ChangeFileContentModification(filePath, newContent))
+            .build());
   }
 
   /**
@@ -285,13 +254,16 @@
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
    * @throws InvalidChangeOperationException if the file does not exist
-   * @throws PermissionBackendException
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void deleteFile(Repository repository, ChangeNotes notes, String file)
       throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
-    modifyTree(repository, notes, new DeleteFileModification(file));
+    modifyCommit(
+        repository,
+        notes,
+        new ModificationIntention.LatestCommit(),
+        CommitModification.builder().addTreeModification(new DeleteFileModification(file)).build());
   }
 
   /**
@@ -306,14 +278,19 @@
    * @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
    * @throws InvalidChangeOperationException if the file was already renamed to the specified new
    *     name
-   * @throws PermissionBackendException
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void renameFile(
       Repository repository, ChangeNotes notes, String currentFilePath, String newFilePath)
       throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
-    modifyTree(repository, notes, new RenameFileModification(currentFilePath, newFilePath));
+    modifyCommit(
+        repository,
+        notes,
+        new ModificationIntention.LatestCommit(),
+        CommitModification.builder()
+            .addTreeModification(new RenameFileModification(currentFilePath, newFilePath))
+            .build());
   }
 
   /**
@@ -326,43 +303,17 @@
    * @param file the path of the file which should be restored
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the file was already restored
-   * @throws PermissionBackendException
    */
   public void restoreFile(Repository repository, ChangeNotes notes, String file)
       throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
-    modifyTree(repository, notes, new RestoreFileModification(file));
-  }
-
-  private void modifyTree(
-      Repository repository, ChangeNotes notes, TreeModification treeModification)
-      throws AuthException, BadRequestException, IOException, InvalidChangeOperationException,
-          PermissionBackendException, ResourceConflictException {
-    assertCanEdit(notes);
-
-    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
-    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
-    RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
-    RevCommit baseCommit =
-        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
-
-    ObjectId newTreeId = createNewTree(repository, baseCommit, ImmutableList.of(treeModification));
-
-    String commitMessage = baseCommit.getFullMessage();
-    Timestamp nowTimestamp = TimeUtil.nowTs();
-    ObjectId newEditCommit =
-        createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
-
-    if (optionalChangeEdit.isPresent()) {
-      updateEdit(
-          notes.getProjectName(),
-          repository,
-          optionalChangeEdit.get(),
-          newEditCommit,
-          nowTimestamp);
-    } else {
-      createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
-    }
+    modifyCommit(
+        repository,
+        notes,
+        new ModificationIntention.LatestCommit(),
+        CommitModification.builder()
+            .addTreeModification(new RestoreFileModification(file))
+            .build());
   }
 
   /**
@@ -373,7 +324,7 @@
    * @param repository the affected Git repository
    * @param notes the {@link ChangeNotes} of the change to which the patch set belongs
    * @param patchSet the {@code PatchSet} which should be modified
-   * @param treeModifications the modifications which should be applied
+   * @param commitModification the modifications which should be applied
    * @return the resulting {@code ChangeEdit}
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the existing change edit is based on another patch
@@ -385,41 +336,61 @@
       Repository repository,
       ChangeNotes notes,
       PatchSet patchSet,
-      List<TreeModification> treeModifications)
+      CommitModification commitModification)
       throws AuthException, BadRequestException, IOException, InvalidChangeOperationException,
-          MergeConflictException, PermissionBackendException, ResourceConflictException {
+          PermissionBackendException, ResourceConflictException {
+    return modifyCommit(
+        repository, notes, new ModificationIntention.PatchsetCommit(patchSet), commitModification);
+  }
+
+  private ChangeEdit modifyCommit(
+      Repository repository,
+      ChangeNotes notes,
+      ModificationIntention modificationIntention,
+      CommitModification commitModification)
+      throws AuthException, BadRequestException, IOException, InvalidChangeOperationException,
+          PermissionBackendException, ResourceConflictException {
     assertCanEdit(notes);
 
     Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
-    ensureAllowedPatchSet(notes, optionalChangeEdit, patchSet);
+    EditBehavior editBehavior =
+        optionalChangeEdit
+            .<EditBehavior>map(changeEdit -> new ExistingEditBehavior(changeEdit, noteDbEdits))
+            .orElseGet(() -> new NewEditBehavior(noteDbEdits));
+    ModificationTarget modificationTarget =
+        editBehavior.getModificationTarget(notes, modificationIntention);
 
-    RevCommit patchSetCommit = lookupCommit(repository, patchSet);
-    ObjectId newTreeId = createNewTree(repository, patchSetCommit, treeModifications);
+    RevCommit commitToModify = modificationTarget.getCommit(repository);
+    ObjectId newTreeId =
+        createNewTree(repository, commitToModify, commitModification.treeModifications());
+    newTreeId = editBehavior.mergeTreesIfNecessary(repository, newTreeId, commitToModify);
 
-    if (optionalChangeEdit.isPresent()) {
-      ChangeEdit changeEdit = optionalChangeEdit.get();
-      newTreeId = merge(repository, changeEdit, newTreeId);
-      if (ObjectId.isEqual(newTreeId, changeEdit.getEditCommit().getTree())) {
-        // Modifications are already contained in the change edit.
-        return changeEdit;
-      }
+    PatchSet basePatchset = modificationTarget.getBasePatchset();
+    RevCommit basePatchsetCommit = NoteDbEdits.lookupCommit(repository, basePatchset.commitId());
+
+    boolean changeIdRequired =
+        projectCache
+            .get(notes.getChange().getProject())
+            .orElseThrow(illegalState(notes.getChange().getProject()))
+            .is(BooleanProjectConfig.REQUIRE_CHANGE_ID);
+    String currentChangeId = notes.getChange().getKey().get();
+    String newCommitMessage =
+        createNewCommitMessage(
+            changeIdRequired, currentChangeId, editBehavior, commitModification, commitToModify);
+    newCommitMessage = editBehavior.mergeCommitMessageIfNecessary(newCommitMessage, commitToModify);
+
+    Optional<ChangeEdit> unmodifiedEdit =
+        editBehavior.getEditIfNoModification(newTreeId, newCommitMessage);
+    if (unmodifiedEdit.isPresent()) {
+      return unmodifiedEdit.get();
     }
 
-    String commitMessage =
-        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(patchSetCommit).getFullMessage();
     Timestamp nowTimestamp = TimeUtil.nowTs();
     ObjectId newEditCommit =
-        createCommit(repository, patchSetCommit, newTreeId, commitMessage, nowTimestamp);
+        createCommit(repository, basePatchsetCommit, newTreeId, newCommitMessage, nowTimestamp);
 
-    if (optionalChangeEdit.isPresent()) {
-      return updateEdit(
-          notes.getProjectName(),
-          repository,
-          optionalChangeEdit.get(),
-          newEditCommit,
-          nowTimestamp);
-    }
-    return createEdit(repository, notes, patchSet, newEditCommit, nowTimestamp);
+    return editBehavior.updateEditInStorage(
+        repository, notes, basePatchset, newEditCommit, nowTimestamp);
   }
 
   private void assertCanEdit(ChangeNotes notes)
@@ -447,40 +418,11 @@
     }
   }
 
-  private static void ensureAllowedPatchSet(
-      ChangeNotes notes, Optional<ChangeEdit> optionalChangeEdit, PatchSet patchSet)
-      throws InvalidChangeOperationException {
-    if (optionalChangeEdit.isPresent()) {
-      ChangeEdit changeEdit = optionalChangeEdit.get();
-      if (!isBasedOn(changeEdit, patchSet)) {
-        throw new InvalidChangeOperationException(
-            String.format(
-                "Only the patch set %s on which the existing change edit is based may be modified "
-                    + "(specified patch set: %s)",
-                changeEdit.getBasePatchSet().id(), patchSet.id()));
-      }
-    } else {
-      PatchSet.Id patchSetId = patchSet.id();
-      PatchSet.Id currentPatchSetId = notes.getChange().currentPatchSetId();
-      if (!patchSetId.equals(currentPatchSetId)) {
-        throw new InvalidChangeOperationException(
-            String.format(
-                "A change edit may only be created for the current patch set %s (and not for %s)",
-                currentPatchSetId, patchSetId));
-      }
-    }
-  }
-
   private Optional<ChangeEdit> lookupChangeEdit(ChangeNotes notes)
       throws AuthException, IOException {
     return changeEditUtil.byChange(notes);
   }
 
-  private PatchSet getBasePatchSet(Optional<ChangeEdit> optionalChangeEdit, ChangeNotes notes) {
-    Optional<PatchSet> editBasePatchSet = optionalChangeEdit.map(ChangeEdit::getBasePatchSet);
-    return editBasePatchSet.isPresent() ? editBasePatchSet.get() : lookupCurrentPatchSet(notes);
-  }
-
   private PatchSet lookupCurrentPatchSet(ChangeNotes notes) {
     return patchSetUtil.current(notes);
   }
@@ -490,25 +432,16 @@
     return editBasePatchSet.id().equals(patchSet.id());
   }
 
-  private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
-      throws IOException {
-    ObjectId patchSetCommitId = patchSet.commitId();
-    return lookupCommit(repository, patchSetCommitId);
-  }
-
-  private static RevCommit lookupCommit(Repository repository, ObjectId commitId)
-      throws IOException {
-    try (RevWalk revWalk = new RevWalk(repository)) {
-      return revWalk.parseCommit(commitId);
-    }
-  }
-
   private static ObjectId createNewTree(
       Repository repository, RevCommit baseCommit, List<TreeModification> treeModifications)
       throws BadRequestException, IOException, InvalidChangeOperationException {
+    if (treeModifications.isEmpty()) {
+      return baseCommit.getTree();
+    }
+
     ObjectId newTreeId;
     try {
-      TreeCreator treeCreator = new TreeCreator(baseCommit);
+      TreeCreator treeCreator = TreeCreator.basedOn(baseCommit);
       treeCreator.addTreeModifications(treeModifications);
       newTreeId = treeCreator.createNewTreeAndGetId(repository);
     } catch (InvalidPathException e) {
@@ -538,9 +471,34 @@
     return threeWayMerger.getResultTreeId();
   }
 
+  private String createNewCommitMessage(
+      boolean requireChangeId,
+      String currentChangeId,
+      EditBehavior editBehavior,
+      CommitModification commitModification,
+      RevCommit commitToModify)
+      throws InvalidChangeOperationException, BadRequestException, ResourceConflictException {
+    if (!commitModification.newCommitMessage().isPresent()) {
+      return editBehavior.getUnmodifiedCommitMessage(commitToModify);
+    }
+
+    String newCommitMessage =
+        CommitMessageUtil.checkAndSanitizeCommitMessage(
+            commitModification.newCommitMessage().get());
+
+    if (newCommitMessage.equals(commitToModify.getFullMessage())) {
+      throw new InvalidChangeOperationException(
+          "New commit message cannot be same as existing commit message");
+    }
+
+    ChangeUtil.ensureChangeIdIsCorrect(requireChangeId, currentChangeId, newCommitMessage);
+
+    return newCommitMessage;
+  }
+
   private ObjectId createCommit(
       Repository repository,
-      RevCommit basePatchSetCommit,
+      RevCommit basePatchsetCommit,
       ObjectId tree,
       String commitMessage,
       Timestamp timestamp)
@@ -548,8 +506,8 @@
     try (ObjectInserter objectInserter = repository.newObjectInserter()) {
       CommitBuilder builder = new CommitBuilder();
       builder.setTreeId(tree);
-      builder.setParentIds(basePatchSetCommit.getParents());
-      builder.setAuthor(basePatchSetCommit.getAuthorIdent());
+      builder.setParentIds(basePatchsetCommit.getParents());
+      builder.setAuthor(basePatchsetCommit.getAuthorIdent());
       builder.setCommitter(getCommitterIdent(timestamp));
       builder.setMessage(commitMessage);
       ObjectId newCommitId = objectInserter.insert(builder);
@@ -563,107 +521,330 @@
     return user.newCommitterIdent(commitTimestamp, tz);
   }
 
-  private ChangeEdit createEdit(
-      Repository repository,
-      ChangeNotes notes,
-      PatchSet basePatchSet,
-      ObjectId newEditCommitId,
-      Timestamp timestamp)
-      throws IOException {
-    Change change = notes.getChange();
-    String editRefName = getEditRefName(change, basePatchSet);
-    updateReference(
-        notes.getProjectName(),
-        repository,
-        editRefName,
-        ObjectId.zeroId(),
-        newEditCommitId,
-        timestamp);
-    reindex(change);
+  /**
+   * Strategy to apply depending on the current situation regarding change edits (e.g. creating a
+   * new edit requires different storage modifications than updating an existing edit).
+   */
+  private interface EditBehavior {
 
-    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
-    return new ChangeEdit(change, editRefName, newEditCommit, basePatchSet);
+    ModificationTarget getModificationTarget(
+        ChangeNotes notes, ModificationIntention targetIntention)
+        throws InvalidChangeOperationException;
+
+    ObjectId mergeTreesIfNecessary(
+        Repository repository, ObjectId newTreeId, ObjectId commitToModify)
+        throws IOException, MergeConflictException;
+
+    String getUnmodifiedCommitMessage(RevCommit commitToModify);
+
+    String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify)
+        throws MergeConflictException;
+
+    Optional<ChangeEdit> getEditIfNoModification(ObjectId newTreeId, String newCommitMessage);
+
+    ChangeEdit updateEditInStorage(
+        Repository repository,
+        ChangeNotes notes,
+        PatchSet basePatchSet,
+        ObjectId newEditCommitId,
+        Timestamp timestamp)
+        throws IOException;
   }
 
-  private String getEditRefName(Change change, PatchSet basePatchSet) {
-    IdentifiedUser me = currentUser.get().asIdentifiedUser();
-    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.id());
-  }
+  private static class ExistingEditBehavior implements EditBehavior {
 
-  private ChangeEdit updateEdit(
-      Project.NameKey projectName,
-      Repository repository,
-      ChangeEdit changeEdit,
-      ObjectId newEditCommitId,
-      Timestamp timestamp)
-      throws IOException {
-    String editRefName = changeEdit.getRefName();
-    RevCommit currentEditCommit = changeEdit.getEditCommit();
-    updateReference(
-        projectName, repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
-    reindex(changeEdit.getChange());
+    private final ChangeEdit changeEdit;
+    private final NoteDbEdits noteDbEdits;
 
-    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
-    return new ChangeEdit(
-        changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
-  }
+    ExistingEditBehavior(ChangeEdit changeEdit, NoteDbEdits noteDbEdits) {
+      this.changeEdit = changeEdit;
+      this.noteDbEdits = noteDbEdits;
+    }
 
-  private void updateReference(
-      Project.NameKey projectName,
-      Repository repository,
-      String refName,
-      ObjectId currentObjectId,
-      ObjectId targetObjectId,
-      Timestamp timestamp)
-      throws IOException {
-    RefUpdate ru = repository.updateRef(refName);
-    ru.setExpectedOldObjectId(currentObjectId);
-    ru.setNewObjectId(targetObjectId);
-    ru.setRefLogIdent(getRefLogIdent(timestamp));
-    ru.setRefLogMessage("inline edit (amend)", false);
-    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);
+    @Override
+    public ModificationTarget getModificationTarget(
+        ChangeNotes notes, ModificationIntention targetIntention)
+        throws InvalidChangeOperationException {
+      ModificationTarget modificationTarget = targetIntention.getTargetWhenEditExists(changeEdit);
+      // It would be better to do this validation in the implementation of the REST endpoints
+      // before calling any write actions on ChangeEditModifier.
+      modificationTarget.ensureTargetMayBeModifiedDespiteExistingEdit(changeEdit);
+      return modificationTarget;
+    }
+
+    @Override
+    public ObjectId mergeTreesIfNecessary(
+        Repository repository, ObjectId newTreeId, ObjectId commitToModify)
+        throws IOException, MergeConflictException {
+      if (ObjectId.isEqual(changeEdit.getEditCommit(), commitToModify)) {
+        return newTreeId;
       }
-      if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
-        throw new IOException(message);
+      return merge(repository, changeEdit, newTreeId);
+    }
+
+    @Override
+    public String getUnmodifiedCommitMessage(RevCommit commitToModify) {
+      return changeEdit.getEditCommit().getFullMessage();
+    }
+
+    @Override
+    public String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify)
+        throws MergeConflictException {
+      if (ObjectId.isEqual(changeEdit.getEditCommit(), commitToModify)) {
+        return newCommitMessage;
       }
+      String editCommitMessage = changeEdit.getEditCommit().getFullMessage();
+      if (editCommitMessage.equals(newCommitMessage)) {
+        return editCommitMessage;
+      }
+      return mergeCommitMessage(newCommitMessage, commitToModify, editCommitMessage);
+    }
+
+    private String mergeCommitMessage(
+        String newCommitMessage, RevCommit commitToModify, String editCommitMessage)
+        throws MergeConflictException {
+      MergeAlgorithm mergeAlgorithm =
+          new MergeAlgorithm(DiffAlgorithm.getAlgorithm(SupportedAlgorithm.MYERS));
+      RawText baseMessage = new RawText(commitToModify.getFullMessage().getBytes(Charsets.UTF_8));
+      RawText oldMessage = new RawText(editCommitMessage.getBytes(Charsets.UTF_8));
+      RawText newMessage = new RawText(newCommitMessage.getBytes(Charsets.UTF_8));
+      RawTextComparator textComparator = RawTextComparator.DEFAULT;
+      MergeResult<RawText> mergeResult =
+          mergeAlgorithm.merge(textComparator, baseMessage, oldMessage, newMessage);
+      if (mergeResult.containsConflicts()) {
+        throw new MergeConflictException(
+            "The chosen modification adjusted the commit message. However, the new commit message"
+                + " could not be merged with the commit message of the existing change edit."
+                + " Please manually apply the desired changes to the commit message of the change"
+                + " edit.");
+      }
+
+      StringBuilder resultingCommitMessage = new StringBuilder();
+      for (MergeChunk mergeChunk : mergeResult) {
+        RawText mergedMessagePart = mergeResult.getSequences().get(mergeChunk.getSequenceIndex());
+        resultingCommitMessage.append(
+            mergedMessagePart.getString(mergeChunk.getBegin(), mergeChunk.getEnd(), false));
+      }
+      return resultingCommitMessage.toString();
+    }
+
+    @Override
+    public Optional<ChangeEdit> getEditIfNoModification(
+        ObjectId newTreeId, String newCommitMessage) {
+      if (!ObjectId.isEqual(newTreeId, changeEdit.getEditCommit().getTree())) {
+        return Optional.empty();
+      }
+      if (!Objects.equals(newCommitMessage, changeEdit.getEditCommit().getFullMessage())) {
+        return Optional.empty();
+      }
+      // Modifications are already contained in the change edit.
+      return Optional.of(changeEdit);
+    }
+
+    @Override
+    public ChangeEdit updateEditInStorage(
+        Repository repository,
+        ChangeNotes notes,
+        PatchSet basePatchSet,
+        ObjectId newEditCommitId,
+        Timestamp timestamp)
+        throws IOException {
+      return noteDbEdits.updateEdit(
+          notes.getProjectName(), repository, changeEdit, newEditCommitId, timestamp);
     }
   }
 
-  private void updateReferenceWithNameChange(
-      Repository repository,
-      String currentRefName,
-      ObjectId currentObjectId,
-      String newRefName,
-      ObjectId targetObjectId,
-      Timestamp timestamp)
-      throws IOException {
-    BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
-    batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
-    batchRefUpdate.addCommand(
-        new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
-    batchRefUpdate.setRefLogMessage("rebase edit", false);
-    batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
-    try (RevWalk revWalk = new RevWalk(repository)) {
-      batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+  private static class NewEditBehavior implements EditBehavior {
+
+    private final NoteDbEdits noteDbEdits;
+
+    NewEditBehavior(NoteDbEdits noteDbEdits) {
+      this.noteDbEdits = noteDbEdits;
     }
-    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("failed: " + cmd);
+
+    @Override
+    public ModificationTarget getModificationTarget(
+        ChangeNotes notes, ModificationIntention targetIntention)
+        throws InvalidChangeOperationException {
+      ModificationTarget modificationTarget = targetIntention.getTargetWhenNoEdit(notes);
+      // It would be better to do this validation in the implementation of the REST endpoints
+      // before calling any write actions on ChangeEditModifier.
+      modificationTarget.ensureNewEditMayBeBasedOnTarget(notes.getChange());
+      return modificationTarget;
+    }
+
+    @Override
+    public ObjectId mergeTreesIfNecessary(
+        Repository repository, ObjectId newTreeId, ObjectId commitToModify) {
+      return newTreeId;
+    }
+
+    @Override
+    public String getUnmodifiedCommitMessage(RevCommit commitToModify) {
+      return commitToModify.getFullMessage();
+    }
+
+    @Override
+    public String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify) {
+      return newCommitMessage;
+    }
+
+    @Override
+    public Optional<ChangeEdit> getEditIfNoModification(
+        ObjectId newTreeId, String newCommitMessage) {
+      return Optional.empty();
+    }
+
+    @Override
+    public ChangeEdit updateEditInStorage(
+        Repository repository,
+        ChangeNotes notes,
+        PatchSet basePatchSet,
+        ObjectId newEditCommitId,
+        Timestamp timestamp)
+        throws IOException {
+      return noteDbEdits.createEdit(repository, notes, basePatchSet, newEditCommitId, timestamp);
+    }
+  }
+
+  private static class NoteDbEdits {
+    private final TimeZone tz;
+    private final ChangeIndexer indexer;
+    private final Provider<CurrentUser> currentUser;
+
+    NoteDbEdits(TimeZone tz, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
+      this.tz = tz;
+      this.indexer = indexer;
+      this.currentUser = currentUser;
+    }
+
+    ChangeEdit createEdit(
+        Repository repository,
+        ChangeNotes notes,
+        PatchSet basePatchset,
+        ObjectId newEditCommitId,
+        Timestamp timestamp)
+        throws IOException {
+      Change change = notes.getChange();
+      String editRefName = getEditRefName(change, basePatchset);
+      updateReference(
+          notes.getProjectName(),
+          repository,
+          editRefName,
+          ObjectId.zeroId(),
+          newEditCommitId,
+          timestamp);
+      reindex(change);
+
+      RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+      return new ChangeEdit(change, editRefName, newEditCommit, basePatchset);
+    }
+
+    private String getEditRefName(Change change, PatchSet basePatchset) {
+      IdentifiedUser me = currentUser.get().asIdentifiedUser();
+      return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchset.id());
+    }
+
+    ChangeEdit updateEdit(
+        Project.NameKey projectName,
+        Repository repository,
+        ChangeEdit changeEdit,
+        ObjectId newEditCommitId,
+        Timestamp timestamp)
+        throws IOException {
+      String editRefName = changeEdit.getRefName();
+      RevCommit currentEditCommit = changeEdit.getEditCommit();
+      updateReference(
+          projectName, repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
+      reindex(changeEdit.getChange());
+
+      RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+      return new ChangeEdit(
+          changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
+    }
+
+    private void updateReference(
+        Project.NameKey projectName,
+        Repository repository,
+        String refName,
+        ObjectId currentObjectId,
+        ObjectId targetObjectId,
+        Timestamp timestamp)
+        throws IOException {
+      RefUpdate ru = repository.updateRef(refName);
+      ru.setExpectedOldObjectId(currentObjectId);
+      ru.setNewObjectId(targetObjectId);
+      ru.setRefLogIdent(getRefLogIdent(timestamp));
+      ru.setRefLogMessage("inline edit (amend)", false);
+      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(message);
+        }
       }
     }
-  }
 
-  private PersonIdent getRefLogIdent(Timestamp timestamp) {
-    IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newRefLogIdent(timestamp, tz);
-  }
+    void baseEditOnDifferentPatchset(
+        Repository repository,
+        ChangeEdit changeEdit,
+        PatchSet currentPatchSet,
+        ObjectId currentEditCommit,
+        ObjectId newEditCommitId,
+        Timestamp nowTimestamp)
+        throws IOException {
+      String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
+      updateReferenceWithNameChange(
+          repository,
+          changeEdit.getRefName(),
+          currentEditCommit,
+          newEditRefName,
+          newEditCommitId,
+          nowTimestamp);
+      reindex(changeEdit.getChange());
+    }
 
-  private void reindex(Change change) {
-    indexer.index(change);
+    private void updateReferenceWithNameChange(
+        Repository repository,
+        String currentRefName,
+        ObjectId currentObjectId,
+        String newRefName,
+        ObjectId targetObjectId,
+        Timestamp timestamp)
+        throws IOException {
+      BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
+      batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
+      batchRefUpdate.addCommand(
+          new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
+      batchRefUpdate.setRefLogMessage("rebase edit", false);
+      batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
+      try (RevWalk revWalk = new RevWalk(repository)) {
+        batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+      }
+      for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+        if (cmd.getResult() != ReceiveCommand.Result.OK) {
+          throw new IOException("failed: " + cmd);
+        }
+      }
+    }
+
+    static RevCommit lookupCommit(Repository repository, ObjectId commitId) throws IOException {
+      try (RevWalk revWalk = new RevWalk(repository)) {
+        return revWalk.parseCommit(commitId);
+      }
+    }
+
+    private PersonIdent getRefLogIdent(Timestamp timestamp) {
+      IdentifiedUser user = currentUser.get().asIdentifiedUser();
+      return user.newRefLogIdent(timestamp, tz);
+    }
+
+    private void reindex(Change change) {
+      indexer.index(change);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/edit/CommitModification.java b/java/com/google/gerrit/server/edit/CommitModification.java
new file mode 100644
index 0000000..f9ed58e
--- /dev/null
+++ b/java/com/google/gerrit/server/edit/CommitModification.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.edit;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.Optional;
+
+@AutoValue
+public abstract class CommitModification {
+
+  public abstract ImmutableList<TreeModification> treeModifications();
+
+  public abstract Optional<String> newCommitMessage();
+
+  public static Builder builder() {
+    return new AutoValue_CommitModification.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public Builder addTreeModification(TreeModification treeModification) {
+      treeModificationsBuilder().add(treeModification);
+      return this;
+    }
+
+    abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+    public abstract Builder treeModifications(ImmutableList<TreeModification> treeModifications);
+
+    public abstract Builder newCommitMessage(String newCommitMessage);
+
+    public abstract CommitModification build();
+  }
+}
diff --git a/java/com/google/gerrit/server/edit/ModificationIntention.java b/java/com/google/gerrit/server/edit/ModificationIntention.java
new file mode 100644
index 0000000..531f682
--- /dev/null
+++ b/java/com/google/gerrit/server/edit/ModificationIntention.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.edit;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.notedb.ChangeNotes;
+
+/**
+ * Intended modification target.
+ *
+ * <p>See also {@link ModificationTarget}. Some modifications may have a fixed target (e.g.
+ * suggested fixes of robot comments). For other modifications, the presence of a change edit
+ * influences their target. The latter comes from the REST endpoints of change edits which work no
+ * matter whether a change edit is present or not. If it's not present, a new change edit is created
+ * based on the current patchset. As we don't want to create an "empty" commit for the new change
+ * edit first, we need this class/interface for the flexible handling.
+ */
+interface ModificationIntention {
+
+  ModificationTarget getTargetWhenEditExists(ChangeEdit changeEdit);
+
+  ModificationTarget getTargetWhenNoEdit(ChangeNotes notes);
+
+  /** A specific patchset is the modification target. */
+  class PatchsetCommit implements ModificationIntention {
+
+    private final PatchSet patchSet;
+
+    PatchsetCommit(PatchSet patchSet) {
+      this.patchSet = patchSet;
+    }
+
+    @Override
+    public ModificationTarget getTargetWhenEditExists(ChangeEdit changeEdit) {
+      return new ModificationTarget.PatchsetCommit(patchSet);
+    }
+
+    @Override
+    public ModificationTarget getTargetWhenNoEdit(ChangeNotes notes) {
+      return new ModificationTarget.PatchsetCommit(patchSet);
+    }
+  }
+
+  /**
+   * The latest commit should be the modification target. If a change edit exists, it's considered
+   * to be the latest commit. Otherwise, defer to the latest patchset commit.
+   */
+  class LatestCommit implements ModificationIntention {
+
+    @Override
+    public ModificationTarget getTargetWhenEditExists(ChangeEdit changeEdit) {
+      return new ModificationTarget.EditCommit(changeEdit);
+    }
+
+    @Override
+    public ModificationTarget getTargetWhenNoEdit(ChangeNotes notes) {
+      return new ModificationTarget.PatchsetCommit(notes.getCurrentPatchSet());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/edit/ModificationTarget.java b/java/com/google/gerrit/server/edit/ModificationTarget.java
new file mode 100644
index 0000000..0de0149
--- /dev/null
+++ b/java/com/google/gerrit/server/edit/ModificationTarget.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.edit;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Target of the modification of a commit.
+ *
+ * <p>This is currently used in the context of change edits which involves both direct actions on
+ * change edits (e.g. creating a change edit; modifying a file of a change edit) as well as indirect
+ * creation/modification of them (e.g. via applying a suggested fix of a robot comment.)
+ *
+ * <p>Depending on the situation and exact action, either an existing {@link ChangeEdit} (-> {@link
+ * EditCommit} or a specific patchset commit (-> {@link PatchsetCommit}) is the target of a
+ * modification.
+ */
+public interface ModificationTarget {
+
+  void ensureNewEditMayBeBasedOnTarget(Change change) throws InvalidChangeOperationException;
+
+  void ensureTargetMayBeModifiedDespiteExistingEdit(ChangeEdit changeEdit)
+      throws InvalidChangeOperationException;
+
+  /** Commit to modify. */
+  RevCommit getCommit(Repository repository) throws IOException;
+
+  /**
+   * Patchset within whose context the modification happens. This also applies to change edits as
+   * each change edit is based on a specific patchset.
+   */
+  PatchSet getBasePatchset();
+
+  /** A specific patchset commit is the target of the modification. */
+  class PatchsetCommit implements ModificationTarget {
+
+    private final PatchSet patchset;
+
+    PatchsetCommit(PatchSet patchset) {
+      this.patchset = patchset;
+    }
+
+    @Override
+    public void ensureTargetMayBeModifiedDespiteExistingEdit(ChangeEdit changeEdit)
+        throws InvalidChangeOperationException {
+      if (!isBasedOn(changeEdit, patchset)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "Only the patch set %s on which the existing change edit is based may be modified "
+                    + "(specified patch set: %s)",
+                changeEdit.getBasePatchSet().id(), patchset.id()));
+      }
+    }
+
+    private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
+      PatchSet editBasePatchSet = changeEdit.getBasePatchSet();
+      return editBasePatchSet.id().equals(patchSet.id());
+    }
+
+    @Override
+    public void ensureNewEditMayBeBasedOnTarget(Change change)
+        throws InvalidChangeOperationException {
+      PatchSet.Id patchSetId = patchset.id();
+      PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+      if (!patchSetId.equals(currentPatchSetId)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "A change edit may only be created for the current patch set %s (and not for %s)",
+                currentPatchSetId, patchSetId));
+      }
+    }
+
+    @Override
+    public RevCommit getCommit(Repository repository) throws IOException {
+      try (RevWalk revWalk = new RevWalk(repository)) {
+        return revWalk.parseCommit(patchset.commitId());
+      }
+    }
+
+    @Override
+    public PatchSet getBasePatchset() {
+      return patchset;
+    }
+  }
+
+  /** An existing {@link ChangeEdit} commit is the target of the modification. */
+  class EditCommit implements ModificationTarget {
+
+    private final ChangeEdit changeEdit;
+
+    EditCommit(ChangeEdit changeEdit) {
+      this.changeEdit = changeEdit;
+    }
+
+    @Override
+    public void ensureNewEditMayBeBasedOnTarget(Change change) {
+      // The current code will never create a new edit if one already exists. It would be a
+      // programmer error if this changes in the future (without adjusting the storage of change
+      // edits).
+      throw new IllegalStateException(
+          String.format(
+              "Change %d already has a change edit for the calling user. A new change edit can't"
+                  + " be created.",
+              changeEdit.getChange().getChangeId()));
+    }
+
+    @Override
+    public void ensureTargetMayBeModifiedDespiteExistingEdit(ChangeEdit changeEdit) {
+      // The target is the change edit and hence can be modified.
+    }
+
+    @Override
+    public RevCommit getCommit(Repository repository) throws IOException {
+      return changeEdit.getEditCommit();
+    }
+
+    @Override
+    public PatchSet getBasePatchset() {
+      return changeEdit.getBasePatchSet();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 0adacd8..39ab041 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -18,6 +18,8 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.extensions.restapi.RawInput;
@@ -33,7 +35,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 /** A {@code TreeModification} which changes the content of a file. */
 public class ChangeFileContentModification implements TreeModification {
@@ -48,14 +49,15 @@
   }
 
   @Override
-  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+  public List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
     DirCacheEditor.PathEdit changeContentEdit = new ChangeContent(filePath, newContent, repository);
     return Collections.singletonList(changeContentEdit);
   }
 
   @Override
-  public String getFilePath() {
-    return filePath;
+  public ImmutableSet<String> getFilePaths() {
+    return ImmutableSet.of(filePath);
   }
 
   @VisibleForTesting
diff --git a/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java b/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
index feffb70..a725257 100644
--- a/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 /** A {@code TreeModification} which deletes a file. */
 public class DeleteFileModification implements TreeModification {
@@ -30,13 +32,14 @@
   }
 
   @Override
-  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+  public List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
     DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(filePath);
     return Collections.singletonList(deletePathEdit);
   }
 
   @Override
-  public String getFilePath() {
-    return filePath;
+  public ImmutableSet<String> getFilePaths() {
+    return ImmutableSet.of(filePath);
   }
 }
diff --git a/java/com/google/gerrit/server/edit/tree/RenameFileModification.java b/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
index b847599..654d904 100644
--- a/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
-import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
 
@@ -36,25 +36,29 @@
   }
 
   @Override
-  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+  public List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
       throws IOException {
+    if (ObjectId.zeroId().equals(treeId)) {
+      return ImmutableList.of();
+    }
+
     try (RevWalk revWalk = new RevWalk(repository)) {
-      revWalk.parseHeaders(baseCommit);
       try (TreeWalk treeWalk =
-          TreeWalk.forPath(revWalk.getObjectReader(), currentFilePath, baseCommit.getTree())) {
+          TreeWalk.forPath(revWalk.getObjectReader(), currentFilePath, treeId)) {
         if (treeWalk == null) {
-          return Collections.emptyList();
+          return ImmutableList.of();
         }
         DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(currentFilePath);
         AddPath addPathEdit =
             new AddPath(newFilePath, treeWalk.getFileMode(0), treeWalk.getObjectId(0));
-        return Arrays.asList(deletePathEdit, addPathEdit);
+        return ImmutableList.of(deletePathEdit, addPathEdit);
       }
     }
   }
 
   @Override
-  public String getFilePath() {
-    return newFilePath;
+  public ImmutableSet<String> getFilePaths() {
+    return ImmutableSet.of(currentFilePath, newFilePath);
   }
 }
diff --git a/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java b/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
index 393a866..f6fd0d7 100644
--- a/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -36,16 +39,16 @@
   }
 
   @Override
-  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+  public List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
       throws IOException {
-    if (baseCommit.getParentCount() == 0) {
+    if (parents.isEmpty()) {
       DirCacheEditor.DeletePath deletePath = new DirCacheEditor.DeletePath(filePath);
       return Collections.singletonList(deletePath);
     }
 
-    RevCommit base = baseCommit.getParent(0);
     try (RevWalk revWalk = new RevWalk(repository)) {
-      revWalk.parseHeaders(base);
+      RevCommit base = revWalk.parseCommit(parents.get(0));
       try (TreeWalk treeWalk =
           TreeWalk.forPath(revWalk.getObjectReader(), filePath, base.getTree())) {
         if (treeWalk == null) {
@@ -60,7 +63,7 @@
   }
 
   @Override
-  public String getFilePath() {
-    return filePath;
+  public ImmutableSet<String> getFilePaths() {
+    return ImmutableSet.of(filePath);
   }
 }
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index e6caf97..dfc1ffb 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -31,20 +33,41 @@
 
 /**
  * A creator for a new Git tree. To create the new tree, the tree of another commit is taken as a
- * basis and modified.
+ * basis and modified. Alternatively, an empty tree can serve as base.
  */
 public class TreeCreator {
 
-  private final RevCommit baseCommit;
+  private final ObjectId baseTreeId;
+  private final ImmutableList<? extends ObjectId> baseParents;
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
-  public TreeCreator(RevCommit baseCommit) {
-    this.baseCommit = requireNonNull(baseCommit, "baseCommit is required");
+  public static TreeCreator basedOn(RevCommit baseCommit) {
+    requireNonNull(baseCommit, "baseCommit is required");
+    return new TreeCreator(baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()));
+  }
+
+  public static TreeCreator basedOnTree(
+      ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+    requireNonNull(baseTreeId, "baseTreeId is required");
+    return new TreeCreator(baseTreeId, baseParents);
+  }
+
+  public static TreeCreator basedOnEmptyTree() {
+    return new TreeCreator(ObjectId.zeroId(), ImmutableList.of());
+  }
+
+  private TreeCreator(ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+    this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
+    this.baseParents = baseParents;
   }
 
   /**
    * Apply modifications to the tree which is taken as a basis. If this method is called multiple
-   * times, the modifications are applied subsequently in exactly the order they were provided.
+   * times, the modifications are applied subsequently in exactly the order they were provided
+   * (though JGit applies some internal optimizations which involve sorting, too).
+   *
+   * <p><strong>Beware:</strong> All provided {@link TreeModification}s (even from previous calls of
+   * this method) must touch different file paths!
    *
    * @param treeModifications modifications which should be applied to the base tree
    */
@@ -63,10 +86,33 @@
    * @throws IOException if problems arise when accessing the repository
    */
   public ObjectId createNewTreeAndGetId(Repository repository) throws IOException {
+    ensureTreeModificationsDoNotTouchSameFiles();
     DirCache newTree = createNewTree(repository);
     return writeAndGetId(repository, newTree);
   }
 
+  private void ensureTreeModificationsDoNotTouchSameFiles() {
+    // The current implementation of TreeCreator doesn't properly support modifications which touch
+    // the same files even if they are provided in a logical order. One reason for this is that
+    // JGit's DirCache implementation sorts the given path edits which is necessary due to the
+    // nature of the Git index. The internal sorting doesn't seem to be the only issue, though. Even
+    // applying the modifications in batches within different, subsequent DirCaches just held in
+    // memory didn't seem to work. We might need to fully write each batch to disk before creating
+    // the next.
+    ImmutableList<String> filePaths =
+        treeModifications.stream()
+            .flatMap(treeModification -> treeModification.getFilePaths().stream())
+            .collect(toImmutableList());
+    long distinctFilePathNum = filePaths.stream().distinct().count();
+    if (filePaths.size() != distinctFilePathNum) {
+      throw new IllegalStateException(
+          String.format(
+              "TreeModifications must not refer to the same file paths. This would have"
+                  + " unexpected/wrong behavior! Found file paths: %s.",
+              filePaths));
+    }
+  }
+
   private DirCache createNewTree(Repository repository) throws IOException {
     DirCache newTree = readBaseTree(repository);
     List<DirCacheEditor.PathEdit> pathEdits = getPathEdits(repository);
@@ -78,8 +124,9 @@
     try (ObjectReader objectReader = repository.newObjectReader()) {
       DirCache dirCache = DirCache.newInCore();
       DirCacheBuilder dirCacheBuilder = dirCache.builder();
-      dirCacheBuilder.addTree(
-          new byte[0], DirCacheEntry.STAGE_0, objectReader, baseCommit.getTree());
+      if (!ObjectId.zeroId().equals(baseTreeId)) {
+        dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, objectReader, baseTreeId);
+      }
       dirCacheBuilder.finish();
       return dirCache;
     }
@@ -88,7 +135,8 @@
   private List<DirCacheEditor.PathEdit> getPathEdits(Repository repository) throws IOException {
     List<DirCacheEditor.PathEdit> pathEdits = new ArrayList<>();
     for (TreeModification treeModification : treeModifications) {
-      pathEdits.addAll(treeModification.getPathEdits(repository, baseCommit));
+      pathEdits.addAll(
+          treeModification.getPathEdits(repository, baseTreeId, ImmutableList.copyOf(baseParents)));
     }
     return pathEdits;
   }
diff --git a/java/com/google/gerrit/server/edit/tree/TreeModification.java b/java/com/google/gerrit/server/edit/tree/TreeModification.java
index 2656707..ba301fc 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeModification.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeModification.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.edit.tree;
 
-import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 /** A specific modification of a Git tree. */
 public interface TreeModification {
@@ -30,20 +31,21 @@
    * shouldn't be changed.
    *
    * @param repository the affected Git repository
-   * @param baseCommit the commit to whose tree this modification is applied
+   * @param treeId tree to which the modification is applied. A value of {@code ObjectId.zero()}
+   *     indicates an empty tree.
+   * @param parents parent commits of the commit to whose tree this modification is applied
    * @return an ordered list of necessary {@code PathEdit}s
    * @throws IOException if problems arise when accessing the repository
    */
-  List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+  List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
       throws IOException;
 
   /**
-   * Indicates a file path which is affected by this {@code TreeModification}. If the modification
-   * refers to several file paths (e.g. renaming a file), returning either of them is appropriate as
-   * long as the returned value is deterministic.
+   * Indicates all file paths affected by this {@code TreeModification}. If the modification refers
+   * to several file paths (e.g. renaming a file), all of them must be returned.
    *
-   * @return an affected file path
+   * @return all affected file paths
    */
-  @VisibleForTesting
-  String getFilePath();
+  ImmutableSet<String> getFilePaths();
 }
diff --git a/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index 6e43621..eb4d9ee 100644
--- a/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -28,6 +29,7 @@
   public ReceiveCommand command;
   public Project project;
   public String refName;
+  public Config repoConfig;
   public RevWalk revWalk;
   public RevCommit commit;
   public IdentifiedUser user;
@@ -40,6 +42,7 @@
       ReceiveCommand command,
       Project project,
       String refName,
+      Config repoConfig,
       ObjectReader reader,
       ObjectId commitId,
       IdentifiedUser user)
@@ -48,6 +51,7 @@
     this.command = command;
     this.project = project;
     this.refName = refName;
+    this.repoConfig = repoConfig;
     this.revWalk = new RevWalk(reader);
     this.commit = revWalk.parseCommit(commitId);
     this.user = user;
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index f0da560..1382be1 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -20,17 +20,17 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-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.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -124,12 +124,6 @@
     a.id = change.getKey().get();
     a.number = change.getId().get();
     a.subject = change.getSubject();
-    try {
-      a.commitMessage = changeDataFactory.create(change).commitMessage();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log(
-          "Error while getting full commit message for change %d", a.number);
-    }
     a.url = getChangeUrl(change);
     a.owner = asAccountAttribute(change.getOwner());
     a.assignee = asAccountAttribute(change.getAssignee());
@@ -147,11 +141,8 @@
   /** 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();
-    if (!hashtags.isEmpty()) {
-      a.hashtags = new ArrayList<>(hashtags.size());
-      a.hashtags.addAll(hashtags);
-    }
+    addHashTags(a, notes);
+    addCommitMessage(a, notes);
     return a;
   }
   /**
@@ -347,6 +338,15 @@
     a.commitMessage = commitMessage;
   }
 
+  private void addCommitMessage(ChangeAttribute changeAttribute, ChangeNotes notes) {
+    try {
+      addCommitMessage(changeAttribute, changeDataFactory.create(notes).commitMessage());
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log(
+          "Error while getting full commit message for change %d", changeAttribute.number);
+    }
+  }
+
   public void addPatchSets(
       RevWalk revWalk,
       ChangeAttribute ca,
@@ -380,8 +380,8 @@
   }
 
   public void addPatchSetComments(
-      PatchSetAttribute patchSetAttribute, Collection<Comment> comments) {
-    for (Comment comment : comments) {
+      PatchSetAttribute patchSetAttribute, Collection<HumanComment> comments) {
+    for (HumanComment comment : comments) {
       if (comment.key.patchSetId == patchSetAttribute.number) {
         if (patchSetAttribute.comments == null) {
           patchSetAttribute.comments = new ArrayList<>();
@@ -547,7 +547,7 @@
     return a;
   }
 
-  public PatchSetCommentAttribute asPatchSetLineAttribute(Comment c) {
+  public PatchSetCommentAttribute asPatchSetLineAttribute(HumanComment c) {
     PatchSetCommentAttribute a = new PatchSetCommentAttribute();
     a.reviewer = asAccountAttribute(c.author.getId());
     a.file = c.key.filename;
@@ -563,4 +563,12 @@
     }
     return null;
   }
+
+  private void addHashTags(ChangeAttribute changeAttribute, ChangeNotes notes) {
+    Set<String> hashtags = notes.load().getHashtags();
+    if (!hashtags.isEmpty()) {
+      changeAttribute.hashtags = new ArrayList<>(hashtags.size());
+      changeAttribute.hashtags.addAll(hashtags);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index f286eef..439f53e 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -20,11 +20,11 @@
 import com.google.common.base.Suppliers;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -140,7 +140,8 @@
 
   private ChangeNotes getNotes(ChangeInfo info) {
     try {
-      return changeNotesFactory.createChecked(Change.id(info._number));
+      return changeNotesFactory.createChecked(
+          Project.nameKey(info.project), Change.id(info._number));
     } catch (NoSuchChangeException e) {
       throw new StorageException(e);
     }
diff --git a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
index 72a5176..cce289a 100644
--- a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
+++ b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
@@ -14,26 +14,33 @@
 
 package com.google.gerrit.server.fixes;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.groupingBy;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Comment.Range;
 import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.Patch;
 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.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.CommitModification;
 import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
 import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.patch.MagicFile;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 
 /** An interpreter for {@code FixReplacement}s. */
@@ -60,7 +67,7 @@
    * @throws ResourceConflictException if the replacements can't be transformed into {@code
    *     TreeModification}s
    */
-  public List<TreeModification> toTreeModifications(
+  public CommitModification toCommitModification(
       Repository repository,
       ProjectState projectState,
       ObjectId patchSetCommitId,
@@ -72,14 +79,63 @@
     Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
         fixReplacements.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
 
-    List<TreeModification> treeModifications = new ArrayList<>();
+    CommitModification.Builder modificationBuilder = CommitModification.builder();
     for (Map.Entry<String, List<FixReplacement>> entry : fixReplacementsPerFilePath.entrySet()) {
-      TreeModification treeModification =
-          toTreeModification(
-              repository, projectState, patchSetCommitId, entry.getKey(), entry.getValue());
-      treeModifications.add(treeModification);
+      if (Objects.equals(entry.getKey(), Patch.COMMIT_MSG)) {
+        String newCommitMessage =
+            getNewCommitMessage(repository, patchSetCommitId, entry.getValue());
+        modificationBuilder.newCommitMessage(newCommitMessage);
+      } else {
+        TreeModification treeModification =
+            toTreeModification(
+                repository, projectState, patchSetCommitId, entry.getKey(), entry.getValue());
+        modificationBuilder.addTreeModification(treeModification);
+      }
     }
-    return treeModifications;
+    return modificationBuilder.build();
+  }
+
+  private static String getNewCommitMessage(
+      Repository repository, ObjectId patchSetCommitId, List<FixReplacement> fixReplacements)
+      throws ResourceConflictException, IOException {
+    try (ObjectReader reader = repository.newObjectReader()) {
+      // In the magic /COMMIT_MSG file, the actual commit message is placed after some generated
+      // header lines. -> Need to find out to which actual line of the commit message a replacement
+      // refers.
+      MagicFile commitMessageFile = MagicFile.forCommitMessage(reader, patchSetCommitId);
+      int commitMessageStartLine = commitMessageFile.getStartLineOfModifiableContent();
+      // Line numbers are 1-based. -> Add 1 to not move first line.
+      // Move up for any additionally found lines.
+      int necessaryRangeShift = -commitMessageStartLine + 1;
+      ImmutableList<FixReplacement> adjustedReplacements =
+          shiftRangesBy(fixReplacements, necessaryRangeShift);
+      if (referToNonPositiveLine(adjustedReplacements)) {
+        throw new ResourceConflictException(
+            String.format("The header of the %s file cannot be modified.", Patch.COMMIT_MSG));
+      }
+      String commitMessage = commitMessageFile.modifiableContent();
+      return FixCalculator.getNewFileContent(commitMessage, adjustedReplacements);
+    }
+  }
+
+  private static ImmutableList<FixReplacement> shiftRangesBy(
+      List<FixReplacement> fixReplacements, int shiftedAmount) {
+    return fixReplacements.stream()
+        .map(replacement -> shiftRangesBy(replacement, shiftedAmount))
+        .collect(toImmutableList());
+  }
+
+  private static FixReplacement shiftRangesBy(FixReplacement fixReplacement, int shiftedAmount) {
+    Range adjustedRange = new Range(fixReplacement.range);
+    adjustedRange.startLine += shiftedAmount;
+    adjustedRange.endLine += shiftedAmount;
+    return new FixReplacement(fixReplacement.path, adjustedRange, fixReplacement.replacement);
+  }
+
+  private static boolean referToNonPositiveLine(List<FixReplacement> adjustedReplacements) {
+    return adjustedReplacements.stream()
+        .map(replacement -> replacement.range)
+        .anyMatch(range -> range.startLine <= 0);
   }
 
   private TreeModification toTreeModification(
diff --git a/java/com/google/gerrit/server/git/BranchOrderSection.java b/java/com/google/gerrit/server/git/BranchOrderSection.java
deleted file mode 100644
index 0266655..0000000
--- a/java/com/google/gerrit/server/git/BranchOrderSection.java
+++ /dev/null
@@ -1,57 +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.git;
-
-import com.google.common.collect.ImmutableList;
-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 {
-
-  /**
-   * Branch names ordered from least to the most stable.
-   *
-   * <p>Typically the order will be like: master, stable-M.N, stable-M.N-1, ...
-   */
-  private final ImmutableList<String> order;
-
-  public BranchOrderSection(String[] order) {
-    if (order.length == 0) {
-      this.order = ImmutableList.of();
-    } else {
-      ImmutableList.Builder<String> builder = ImmutableList.builder();
-      for (String b : order) {
-        builder.add(RefNames.fullName(b));
-      }
-      this.order = builder.build();
-    }
-  }
-
-  public String[] getMoreStable(String branch) {
-    int i = order.indexOf(RefNames.fullName(branch));
-    if (0 <= i) {
-      List<String> r = order.subList(i + 1, order.size());
-      return r.toArray(new String[r.size()]);
-    }
-    return new String[] {};
-  }
-}
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index df53133..47cbd60 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -40,6 +40,7 @@
 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.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -86,6 +87,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeReverted changeReverted;
   private final BatchUpdate.Factory updateFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   CommitUtil(
@@ -98,7 +100,8 @@
       RevertedSender.Factory revertedSenderFactory,
       ChangeMessagesUtil cmUtil,
       ChangeReverted changeReverted,
-      BatchUpdate.Factory updateFactory) {
+      BatchUpdate.Factory updateFactory,
+      MessageIdGenerator messageIdGenerator) {
     this.repoManager = repoManager;
     this.serverIdent = serverIdent;
     this.seq = seq;
@@ -109,6 +112,7 @@
     this.cmUtil = cmUtil;
     this.changeReverted = changeReverted;
     this.updateFactory = updateFactory;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   public static CommitInfo toCommitInfo(RevCommit commit) throws IOException {
@@ -261,6 +265,9 @@
     RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
     Change changeToRevert = notes.getChange();
     Change.Id changeId = Change.id(seq.nextChangeId());
+    if (input.workInProgress) {
+      input.notify = NotifyHandling.OWNER;
+    }
     NotifyResolver.Result notify =
         notifyResolver.resolve(firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
 
@@ -280,6 +287,7 @@
     ccs.remove(user.getAccountId());
     ins.setReviewersAndCcs(reviewers, ccs);
     ins.setRevertOf(notes.getChangeId());
+    ins.setWorkInProgress(input.workInProgress);
 
     try (BatchUpdate bu = updateFactory.create(notes.getProjectName(), user, ts)) {
       bu.setRepository(git, revWalk, oi);
@@ -305,10 +313,12 @@
     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();
+        RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.setNotify(ctx.getNotify(change.getId()));
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot send email for revert change %s", change.getId());
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index 2c523b2..d45a31ed 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -15,14 +15,40 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.common.UsedAt;
+import java.io.File;
 import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.attributes.AttributesNodeProvider;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
+import org.eclipse.jgit.errors.CorruptObjectException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.NoWorkTreeException;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.events.ListenerList;
+import org.eclipse.jgit.events.RepositoryEvent;
+import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.BaseRepositoryBuilder;
 import org.eclipse.jgit.lib.ObjectDatabase;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.RebaseTodoLine;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.ReflogReader;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryState;
 import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.FS;
 
 /** Wrapper around {@link Repository} that delegates all calls to the wrapped {@link Repository}. */
 @UsedAt(UsedAt.Project.PLUGIN_HIGH_AVAILABILITY)
@@ -95,4 +121,279 @@
 
     return new BaseRepositoryBuilder<>().setFS(repo.getFS()).setGitDir(repo.getDirectory());
   }
+
+  @Override
+  public ListenerList getListenerList() {
+    return delegate.getListenerList();
+  }
+
+  @Override
+  public void fireEvent(RepositoryEvent<?> event) {
+    delegate.fireEvent(event);
+  }
+
+  @Override
+  public void create() throws IOException {
+    delegate.create();
+  }
+
+  @Override
+  public File getDirectory() {
+    return delegate.getDirectory();
+  }
+
+  @Override
+  public ObjectInserter newObjectInserter() {
+    return delegate.newObjectInserter();
+  }
+
+  @Override
+  public ObjectReader newObjectReader() {
+    return delegate.newObjectReader();
+  }
+
+  @Override
+  public FS getFS() {
+    return delegate.getFS();
+  }
+
+  @Override
+  @Deprecated
+  public boolean hasObject(AnyObjectId objectId) {
+    return delegate.hasObject(objectId);
+  }
+
+  @Override
+  public ObjectLoader open(AnyObjectId objectId, int typeHint)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+    return delegate.open(objectId, typeHint);
+  }
+
+  @Override
+  public void incrementOpen() {
+    delegate.incrementOpen();
+  }
+
+  @Override
+  public void close() {
+    delegate.close();
+  }
+
+  @Override
+  public String getFullBranch() throws IOException {
+    return delegate.getFullBranch();
+  }
+
+  @Override
+  public String getBranch() throws IOException {
+    return delegate.getBranch();
+  }
+
+  @Override
+  @Deprecated
+  public Map<String, Ref> getAllRefs() {
+    return delegate.getAllRefs();
+  }
+
+  @Override
+  @Deprecated
+  public Map<String, Ref> getTags() {
+    return delegate.getTags();
+  }
+
+  @Override
+  public DirCache lockDirCache() throws NoWorkTreeException, CorruptObjectException, IOException {
+    return delegate.lockDirCache();
+  }
+
+  @Override
+  public void autoGC(ProgressMonitor monitor) {
+    delegate.autoGC(monitor);
+  }
+
+  @Override
+  public Set<ObjectId> getAdditionalHaves() {
+    return delegate.getAdditionalHaves();
+  }
+
+  @Override
+  public Map<AnyObjectId, Set<Ref>> getAllRefsByPeeledObjectId() {
+    return delegate.getAllRefsByPeeledObjectId();
+  }
+
+  @Override
+  public File getIndexFile() throws NoWorkTreeException {
+    return delegate.getIndexFile();
+  }
+
+  @Override
+  public RepositoryState getRepositoryState() {
+    return delegate.getRepositoryState();
+  }
+
+  @Override
+  public boolean isBare() {
+    return delegate.isBare();
+  }
+
+  @Override
+  public File getWorkTree() throws NoWorkTreeException {
+    return delegate.getWorkTree();
+  }
+
+  @Override
+  public String getRemoteName(String refName) {
+    return delegate.getRemoteName(refName);
+  }
+
+  @Override
+  public String getGitwebDescription() throws IOException {
+    return delegate.getGitwebDescription();
+  }
+
+  @Override
+  public Set<String> getRemoteNames() {
+    return delegate.getRemoteNames();
+  }
+
+  @Override
+  public ObjectLoader open(AnyObjectId objectId) throws MissingObjectException, IOException {
+    return delegate.open(objectId);
+  }
+
+  @Override
+  public RefUpdate updateRef(String ref) throws IOException {
+    return delegate.updateRef(ref);
+  }
+
+  @Override
+  public RefUpdate updateRef(String ref, boolean detach) throws IOException {
+    return delegate.updateRef(ref, detach);
+  }
+
+  @Override
+  public RefRename renameRef(String fromRef, String toRef) throws IOException {
+    return delegate.renameRef(fromRef, toRef);
+  }
+
+  @Override
+  public ObjectId resolve(String revstr)
+      throws AmbiguousObjectException, IncorrectObjectTypeException, RevisionSyntaxException,
+          IOException {
+    return delegate.resolve(revstr);
+  }
+
+  @Override
+  public String simplify(String revstr) throws AmbiguousObjectException, IOException {
+    return delegate.simplify(revstr);
+  }
+
+  @Override
+  @Deprecated
+  public Ref peel(Ref ref) {
+    return delegate.peel(ref);
+  }
+
+  @Override
+  public RevCommit parseCommit(AnyObjectId id)
+      throws IncorrectObjectTypeException, IOException, MissingObjectException {
+    return delegate.parseCommit(id);
+  }
+
+  @Override
+  public DirCache readDirCache() throws NoWorkTreeException, CorruptObjectException, IOException {
+    return delegate.readDirCache();
+  }
+
+  @Override
+  public String shortenRemoteBranchName(String refName) {
+    return delegate.shortenRemoteBranchName(refName);
+  }
+
+  @Override
+  public void setGitwebDescription(String description) throws IOException {
+    delegate.setGitwebDescription(description);
+  }
+
+  @Override
+  public String readMergeCommitMsg() throws IOException, NoWorkTreeException {
+    return delegate.readMergeCommitMsg();
+  }
+
+  @Override
+  public void writeMergeCommitMsg(String msg) throws IOException {
+    delegate.writeMergeCommitMsg(msg);
+  }
+
+  @Override
+  public String readCommitEditMsg() throws IOException, NoWorkTreeException {
+    return delegate.readCommitEditMsg();
+  }
+
+  @Override
+  public void writeCommitEditMsg(String msg) throws IOException {
+    delegate.writeCommitEditMsg(msg);
+  }
+
+  @Override
+  public List<ObjectId> readMergeHeads() throws IOException, NoWorkTreeException {
+    return delegate.readMergeHeads();
+  }
+
+  @Override
+  public void writeMergeHeads(List<? extends ObjectId> heads) throws IOException {
+    delegate.writeMergeHeads(heads);
+  }
+
+  @Override
+  public ObjectId readCherryPickHead() throws IOException, NoWorkTreeException {
+    return delegate.readCherryPickHead();
+  }
+
+  @Override
+  public ObjectId readRevertHead() throws IOException, NoWorkTreeException {
+    return delegate.readRevertHead();
+  }
+
+  @Override
+  public void writeCherryPickHead(ObjectId head) throws IOException {
+    delegate.writeCherryPickHead(head);
+  }
+
+  @Override
+  public void writeRevertHead(ObjectId head) throws IOException {
+    delegate.writeRevertHead(head);
+  }
+
+  @Override
+  public void writeOrigHead(ObjectId head) throws IOException {
+    delegate.writeOrigHead(head);
+  }
+
+  @Override
+  public ObjectId readOrigHead() throws IOException, NoWorkTreeException {
+    return delegate.readOrigHead();
+  }
+
+  @Override
+  public String readSquashCommitMsg() throws IOException {
+    return delegate.readSquashCommitMsg();
+  }
+
+  @Override
+  public void writeSquashCommitMsg(String msg) throws IOException {
+    delegate.writeSquashCommitMsg(msg);
+  }
+
+  @Override
+  public List<RebaseTodoLine> readRebaseTodo(String path, boolean includeComments)
+      throws IOException {
+    return delegate.readRebaseTodo(path, includeComments);
+  }
+
+  @Override
+  public void writeRebaseTodoFile(String path, List<RebaseTodoLine> steps, boolean append)
+      throws IOException {
+    delegate.writeRebaseTodoFile(path, steps, append);
+  }
 }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index dccb97a..e0b101b 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -31,12 +31,12 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.exceptions.InvalidMergeStrategyException;
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.UrlFormatter;
@@ -303,13 +304,13 @@
     int nameLength = Math.max(oursName.length(), theirsName.length());
     String oursNameFormatted =
         String.format(
-            "%0$-" + nameLength + "s (%s %s)",
+            "%-" + nameLength + "s (%s %s)",
             oursName,
             abbreviateName(ours, NAME_ABBREV_LEN),
             oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
     String theirsNameFormatted =
         String.format(
-            "%0$-" + nameLength + "s (%s %s)",
+            "%-" + nameLength + "s (%s %s)",
             theirsName,
             abbreviateName(theirs, NAME_ABBREV_LEN),
             theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
@@ -531,7 +532,7 @@
       msgbuf.append('\n');
     }
 
-    if (!contains(footers, FooterConstants.CHANGE_ID, c.getKey().get())) {
+    if (ChangeUtil.getChangeIdsFromFooter(n, urlFormatter.get()).isEmpty()) {
       msgbuf.append(FooterConstants.CHANGE_ID.getName());
       msgbuf.append(": ");
       msgbuf.append(c.getKey().get());
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index b272cba..40e2730 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -70,6 +71,7 @@
   private final PatchSetUtil psUtil;
   private final ExecutorService sendEmailExecutor;
   private final ChangeMerged changeMerged;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final PatchSet.Id psId;
   private final SubmissionId submissionId;
@@ -90,6 +92,7 @@
       PatchSetUtil psUtil,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ChangeMerged changeMerged,
+      MessageIdGenerator messageIdGenerator,
       @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted PatchSet.Id psId,
       @Assisted SubmissionId submissionId,
@@ -101,6 +104,7 @@
     this.psUtil = psUtil;
     this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
+    this.messageIdGenerator = messageIdGenerator;
     this.requestScopePropagator = requestScopePropagator;
     this.submissionId = submissionId;
     this.psId = psId;
@@ -185,11 +189,13 @@
                   @Override
                   public void run() {
                     try {
-                      MergedSender cm =
+                      MergedSender emailSender =
                           mergedSenderFactory.create(ctx.getProject(), psId.changeId());
-                      cm.setFrom(ctx.getAccountId());
-                      cm.setPatchSet(patchSet, info);
-                      cm.send();
+                      emailSender.setFrom(ctx.getAccountId());
+                      emailSender.setPatchSet(patchSet, info);
+                      emailSender.setMessageId(
+                          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+                      emailSender.send();
                     } catch (Exception e) {
                       logger.atSevere().withCause(e).log(
                           "Cannot send email for submitted patch set %s", psId);
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 2d854a5..15fbe3f 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -28,6 +28,8 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ProgressMonitor;
 
@@ -123,6 +125,64 @@
         return count;
       }
     }
+
+    public int getTotal() {
+      return total;
+    }
+
+    public String getName() {
+      return name;
+    }
+
+    public String getTotalDisplay(int total) {
+      return String.valueOf(total);
+    }
+  }
+
+  /** Handle for a sub-task whose total work can be updated while the task is in progress. */
+  public class VolatileTask extends Task {
+    protected AtomicInteger volatileTotal;
+    protected AtomicBoolean isTotalFinalized = new AtomicBoolean(false);
+
+    public VolatileTask(String subTaskName) {
+      super(subTaskName, UNKNOWN);
+      volatileTotal = new AtomicInteger(UNKNOWN);
+    }
+
+    /**
+     * Update the total work for this sub-task.
+     *
+     * <p>Intended to be called from a worker thread.
+     *
+     * @param workUnits number of work units to be added to existing total work.
+     */
+    public void updateTotal(int workUnits) {
+      if (!isTotalFinalized.get()) {
+        volatileTotal.addAndGet(workUnits);
+      } else {
+        logger.atWarning().log(
+            "Total work has been finalized on sub-task %s and cannot be updated", getName());
+      }
+    }
+
+    /**
+     * Mark the total on this sub-task as unmodifiable.
+     *
+     * <p>Intended to be called from a worker thread.
+     */
+    public void finalizeTotal() {
+      isTotalFinalized.set(true);
+    }
+
+    @Override
+    public int getTotal() {
+      return volatileTotal.get();
+    }
+
+    @Override
+    public String getTotalDisplay(int total) {
+      return super.getTotalDisplay(total) + (isTotalFinalized.get() ? "" : "+");
+    }
   }
 
   private final OutputStream out;
@@ -180,6 +240,7 @@
    * calls {@link #end()}, the future has an additional {@code maxInterval} to finish before it is
    * forcefully cancelled and {@link ExecutionException} is thrown.
    *
+   * @see #waitForNonFinalTask(Future, long, TimeUnit)
    * @param workerFuture a future that returns when worker threads are finished.
    * @param timeoutTime overall timeout for the task; the future is forcefully cancelled if the task
    *     exceeds the timeout. Non-positive values indicate no timeout.
@@ -189,6 +250,45 @@
    */
   public <T> T waitFor(Future<T> workerFuture, long timeoutTime, TimeUnit timeoutUnit)
       throws TimeoutException {
+    T t = waitForNonFinalTask(workerFuture, timeoutTime, timeoutUnit);
+    synchronized (this) {
+      if (!done) {
+        // The worker may not have called end() explicitly, which is likely a
+        // programming error.
+        logger.atWarning().log("MultiProgressMonitor worker did not call end() before returning");
+        end();
+      }
+    }
+    sendDone();
+    return t;
+  }
+
+  /**
+   * Wait for a non-final task managed by a {@link Future}, with no timeout.
+   *
+   * @see #waitForNonFinalTask(Future, long, TimeUnit)
+   */
+  public <T> T waitForNonFinalTask(Future<T> workerFuture) {
+    try {
+      return waitForNonFinalTask(workerFuture, 0, null);
+    } catch (TimeoutException e) {
+      throw new IllegalStateException("timout exception without setting a timeout", e);
+    }
+  }
+
+  /**
+   * Wait for a task managed by a {@link Future}. This call does not expect the worker thread to
+   * call {@link #end()}. It is intended to be used to track a non-final task.
+   *
+   * @param workerFuture a future that returns when worker threads are finished.
+   * @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 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 <T> T waitForNonFinalTask(Future<T> workerFuture, long timeoutTime, TimeUnit timeoutUnit)
+      throws TimeoutException {
     long overallStart = System.nanoTime();
     long deadline;
     if (timeoutTime > 0) {
@@ -199,7 +299,7 @@
 
     synchronized (this) {
       long left = maxIntervalNanos;
-      while (!done) {
+      while (!workerFuture.isDone() && !done) {
         long start = System.nanoTime();
         try {
           NANOSECONDS.timedWait(this, left);
@@ -228,14 +328,8 @@
           left = maxIntervalNanos;
         }
         sendUpdate();
-        if (!done && workerFuture.isDone()) {
-          // The worker may not have called end() explicitly, which is likely a
-          // programming error.
-          logger.atWarning().log("MultiProgressMonitor worker did not call end() before returning");
-          end();
-        }
       }
-      sendDone();
+      wakeUp();
     }
 
     // The loop exits as soon as the worker calls end(), but we give it another
@@ -271,6 +365,18 @@
   }
 
   /**
+   * Begin a sub-task whose total work can be updated.
+   *
+   * @param subTask sub-task name.
+   * @return sub-task handle.
+   */
+  public VolatileTask beginVolatileSubTask(String subTask) {
+    VolatileTask task = new VolatileTask(subTask);
+    tasks.add(task);
+    return task;
+  }
+
+  /**
    * End the overall task.
    *
    * <p>Must be called from a worker thread.
@@ -313,6 +419,7 @@
       boolean first = true;
       for (Task t : tasks) {
         int count = t.getCount();
+        int total = t.getTotal();
         if (count == 0) {
           continue;
         }
@@ -327,10 +434,11 @@
         if (!Strings.isNullOrEmpty(t.name)) {
           s.append(t.name).append(": ");
         }
-        if (t.total == UNKNOWN) {
+        if (total == UNKNOWN) {
           s.append(count);
         } else {
-          s.append(String.format("%d%% (%d/%d)", count * 100 / t.total, count, t.total));
+          s.append(
+              String.format("%d%% (%d/%s)", count * 100 / total, count, t.getTotalDisplay(total)));
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/NotifyConfig.java b/java/com/google/gerrit/server/git/NotifyConfig.java
deleted file mode 100644
index 429f15a..0000000
--- a/java/com/google/gerrit/server/git/NotifyConfig.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// 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.git;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import java.util.EnumSet;
-import java.util.HashSet;
-import java.util.Set;
-
-public class NotifyConfig implements Comparable<NotifyConfig> {
-  public enum Header {
-    TO,
-    CC,
-    BCC
-  }
-
-  private String name;
-  private EnumSet<NotifyType> types = EnumSet.of(NotifyType.ALL);
-  private String filter;
-
-  private Header header;
-  private Set<GroupReference> groups = new HashSet<>();
-  private Set<Address> addresses = new HashSet<>();
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String name) {
-    this.name = name;
-  }
-
-  public boolean isNotify(NotifyType type) {
-    return types.contains(type) || types.contains(NotifyType.ALL);
-  }
-
-  public Set<NotifyType> getNotify() {
-    return types;
-  }
-
-  public void setTypes(Set<NotifyType> newTypes) {
-    types = EnumSet.copyOf(newTypes);
-  }
-
-  public String getFilter() {
-    return filter;
-  }
-
-  public void setFilter(String filter) {
-    if ("*".equals(filter)) {
-      this.filter = null;
-    } else {
-      this.filter = Strings.emptyToNull(filter);
-    }
-  }
-
-  public Header getHeader() {
-    return header;
-  }
-
-  public void setHeader(Header hdr) {
-    header = hdr;
-  }
-
-  public Set<GroupReference> getGroups() {
-    return groups;
-  }
-
-  public Set<Address> getAddresses() {
-    return addresses;
-  }
-
-  public void addEmail(GroupReference group) {
-    groups.add(group);
-  }
-
-  public void addEmail(Address address) {
-    addresses.add(address);
-  }
-
-  @Override
-  public int compareTo(NotifyConfig o) {
-    return name.compareTo(o.name);
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (obj instanceof NotifyConfig) {
-      return compareTo((NotifyConfig) obj) == 0;
-    }
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper(this)
-        .add("name", name)
-        .add("addresses", addresses)
-        .add("groups", groups)
-        .add("header", header)
-        .add("types", types)
-        .add("filter", filter)
-        .toString();
-  }
-}
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 196fc61..fed6541 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -165,7 +165,7 @@
         List<CachedChange> result = new ArrayList<>(cds.size());
         for (ChangeData cd : cds) {
           result.add(
-              new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.getReviewers()));
+              new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
         }
         return Collections.unmodifiableList(result);
       }
diff --git a/java/com/google/gerrit/server/git/ValidationError.java b/java/com/google/gerrit/server/git/ValidationError.java
index 28d5171..3606c42 100644
--- a/java/com/google/gerrit/server/git/ValidationError.java
+++ b/java/com/google/gerrit/server/git/ValidationError.java
@@ -14,51 +14,26 @@
 
 package com.google.gerrit.server.git;
 
-import java.util.Objects;
+import com.google.auto.value.AutoValue;
 
 /** Indicates a problem with Git based data. */
-public class ValidationError {
-  private final String message;
+@AutoValue
+public abstract class ValidationError {
+  public abstract String getMessage();
 
-  public ValidationError(String file, String message) {
-    this(file + ": " + message);
+  public static ValidationError create(String file, String message) {
+    return create(file + ": " + message);
   }
 
-  public ValidationError(String file, int line, String message) {
-    this(file + ":" + line + ": " + message);
+  public static ValidationError create(String file, int line, String message) {
+    return create(file + ":" + line + ": " + message);
   }
 
-  public ValidationError(String message) {
-    this.message = message;
-  }
-
-  public String getMessage() {
-    return message;
-  }
-
-  @Override
-  public String toString() {
-    return "ValidationError[" + message + "]";
+  public static ValidationError create(String message) {
+    return new AutoValue_ValidationError(message);
   }
 
   public interface Sink {
     void error(ValidationError error);
   }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o == this) {
-      return true;
-    }
-    if (o instanceof ValidationError) {
-      ValidationError that = (ValidationError) o;
-      return Objects.equals(this.message, that.message);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hashCode(message);
-  }
 }
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index f2a0ff1..4b08040 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -606,9 +606,12 @@
     @Override
     public void run() {
       if (running.compareAndSet(false, true)) {
+        String oldThreadName = Thread.currentThread().getName();
         try {
+          Thread.currentThread().setName(oldThreadName + "[" + task.toString() + "]");
           task.run();
         } finally {
+          Thread.currentThread().setName(oldThreadName);
           if (isPeriodic()) {
             running.set(false);
           } else {
@@ -681,5 +684,10 @@
     public boolean hasCustomizedPrint() {
       return runnable.hasCustomizedPrint();
     }
+
+    @Override
+    public String toString() {
+      return runnable.toString();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index c9a8e77..80570a5 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -59,7 +59,7 @@
 
       int tab = s.indexOf('\t');
       if (tab < 0) {
-        errors.error(new ValidationError(filename, lineNumber, "missing tab delimiter"));
+        errors.error(ValidationError.create(filename, lineNumber, "missing tab delimiter"));
         continue;
       }
 
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 64a7cae..06cc228 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -418,6 +418,16 @@
     metrics.latencyPerPush.record(pushType, deltaNanos, NANOSECONDS);
   }
 
+  /**
+   * Sends all messages which have been collected while processing the push to the client.
+   *
+   * @see ReceiveCommits#sendMessages()
+   */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public void sendMessages() {
+    receiveCommits.sendMessages();
+  }
+
   public ReceivePack getReceivePack() {
     return receivePack;
   }
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index 766a835..b59d431 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -19,6 +19,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index 7b5f90bd..55261223 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -37,7 +37,9 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -94,6 +96,7 @@
   /**
    * Validates a single commit. If the commit does not validate, the command is rejected.
    *
+   * @param repository the repository
    * @param objectReader the object reader to use.
    * @param cmd the ReceiveCommand executing the push.
    * @param commit the commit being validated.
@@ -102,6 +105,7 @@
    * @return The validation {@link Result}.
    */
   Result validateCommit(
+      Repository repository,
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
@@ -109,12 +113,14 @@
       NoteMap rejectCommits,
       @Nullable Change change)
       throws IOException {
-    return validateCommit(objectReader, cmd, commit, isMerged, rejectCommits, change, false);
+    return validateCommit(
+        repository, objectReader, cmd, commit, isMerged, rejectCommits, change, false);
   }
 
   /**
    * Validates a single commit. If the commit does not validate, the command is rejected.
    *
+   * @param repository the repository
    * @param objectReader the object reader to use.
    * @param cmd the ReceiveCommand executing the push.
    * @param commit the commit being validated.
@@ -124,6 +130,7 @@
    * @return The validation {@link Result}.
    */
   Result validateCommit(
+      Repository repository,
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
@@ -135,7 +142,14 @@
     try (TraceTimer traceTimer = TraceContext.newTimer("BranchCommitValidator#validateCommit")) {
       ImmutableList.Builder<CommitValidationMessage> messages = new ImmutableList.Builder<>();
       try (CommitReceivedEvent receiveEvent =
-          new CommitReceivedEvent(cmd, project, branch.branch(), objectReader, commit, user)) {
+          new CommitReceivedEvent(
+              cmd,
+              project,
+              branch.branch(),
+              new Config(repository.getConfig()),
+              objectReader,
+              commit,
+              user)) {
         CommitValidators validators;
         if (isMerged) {
           validators =
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 171b6ad..0209105 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -60,16 +60,15 @@
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.entities.Project;
@@ -117,6 +116,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.BanCommit;
@@ -161,9 +161,9 @@
 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.ReplyAttentionSetUpdates;
 import com.google.gerrit.server.submit.MergeOp;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
-import com.google.gerrit.server.submit.SubmoduleOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -171,6 +171,9 @@
 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.SubmissionExecutor;
+import com.google.gerrit.server.update.SubmissionListener;
+import com.google.gerrit.server.update.SuperprojectUpdateOnSubmission;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
@@ -341,10 +344,12 @@
   private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
-  private final SubmoduleOp.Factory subOpFactory;
+  private final SubmissionListener superprojectUpdateSubmissionListener;
   private final TagCache tagCache;
   private final ProjectConfig.Factory projectConfigFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
+  private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   // Assisted injected fields.
   private final ProjectState projectState;
@@ -421,9 +426,11 @@
       RequestScopePropagator requestScopePropagator,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
-      SubmoduleOp.Factory subOpFactory,
+      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
       TagCache tagCache,
       SetPrivateOp.Factory setPrivateOpFactory,
+      ReplyAttentionSetUpdates replyAttentionSetUpdates,
+      DynamicItem<UrlFormatter> urlFormatter,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
@@ -467,10 +474,12 @@
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
-    this.subOpFactory = subOpFactory;
+    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
     this.tagCache = tagCache;
     this.projectConfigFactory = projectConfigFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
+    this.replyAttentionSetUpdates = replyAttentionSetUpdates;
+    this.urlFormatter = urlFormatter;
 
     // Assisted injected fields.
     this.projectState = projectState;
@@ -712,12 +721,14 @@
         parseRegularCommand(cmd);
       }
 
+      Map<BranchNameKey, ReceiveCommand> branches;
       try (BatchUpdate bu =
               batchUpdateFactory.create(
                   project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
           ObjectInserter ins = repo.newObjectInserter();
           ObjectReader reader = ins.newReader();
-          RevWalk rw = new RevWalk(reader)) {
+          RevWalk rw = new RevWalk(reader);
+          MergeOpRepoManager orm = ormProvider.get()) {
         bu.setRepository(repo, rw, ins);
         bu.setRefLogMessage("push");
 
@@ -729,46 +740,41 @@
           }
         }
         logger.atFine().log("Added %d additional ref updates", added);
-        bu.execute();
+
+        SubmissionExecutor submissionExecutor =
+            new SubmissionExecutor(false, superprojectUpdateSubmissionListener);
+
+        submissionExecutor.execute(ImmutableList.of(bu));
+
+        orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
+        submissionExecutor.afterExecutions(orm);
+
+        branches = bu.getSuccessfullyUpdatedBranches(false);
       } catch (UpdateException | RestApiException e) {
         throw new StorageException(e);
       }
 
-      Set<BranchNameKey> branches = new HashSet<>();
-      for (ReceiveCommand c : cmds) {
-        // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
-        // should happen in this loop are things that can't happen within one BatchUpdate because
-        // they involve kicking off an additional BatchUpdate.
-        if (c.getResult() != OK) {
-          continue;
-        }
-        if (isHead(c) || isConfig(c)) {
-          switch (c.getType()) {
-            case CREATE:
-            case UPDATE:
-            case UPDATE_NONFASTFORWARD:
-              Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
-              autoCloseChanges(c, closeProgress);
-              closeProgress.end();
-              branches.add(BranchNameKey.create(project.getNameKey(), c.getRefName()));
-              break;
+      // This could be moved into a SubmissionListener
+      branches.values().stream()
+          .filter(c -> isHead(c) || isConfig(c))
+          .forEach(
+              c -> {
+                // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps
+                // that should happen in this loops are things that can't happen within one
+                // BatchUpdate because they involve kicking off an additional BatchUpdate.
+                switch (c.getType()) {
+                  case CREATE:
+                  case UPDATE:
+                  case UPDATE_NONFASTFORWARD:
+                    Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
+                    autoCloseChanges(c, closeProgress);
+                    closeProgress.end();
+                    break;
 
-            case DELETE:
-              break;
-          }
-        }
-      }
-
-      // Update superproject gitlinks if required.
-      if (!branches.isEmpty()) {
-        try (MergeOpRepoManager orm = ormProvider.get()) {
-          orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
-          SubmoduleOp op = subOpFactory.create(branches, orm);
-          op.updateSuperProjects();
-        } catch (RestApiException e) {
-          logger.atWarning().withCause(e).log("Can't update the superprojects");
-        }
-      }
+                  case DELETE:
+                    break;
+                }
+              });
     }
   }
 
@@ -922,6 +928,19 @@
               bu.addOp(
                   replace.notes.getChangeId(),
                   publishCommentsOp.create(replace.psId, project.getNameKey()));
+              Optional<ChangeNotes> changeNotes = getChangeNotes(replace.notes.getChangeId());
+              if (!changeNotes.isPresent()) {
+                // If not present, no need to update attention set here since this is a new change.
+                continue;
+              }
+              List<HumanComment> drafts =
+                  commentsUtil.draftByChangeAuthor(changeNotes.get(), user.getAccountId());
+              if (drafts.isEmpty()) {
+                // If no comments, attention set shouldn't update since the user didn't reply.
+                continue;
+              }
+              replyAttentionSetUpdates.processAutomaticAttentionSetRulesOnReply(
+                  bu, changeNotes.get(), isReadyForReview(changeNotes.get()), user, drafts);
             }
           }
         }
@@ -935,17 +954,9 @@
         updateGroups.forEach(r -> r.addOps(bu));
 
         logger.atFine().log("Executing batch");
-
         try {
-          retryHelper
-              .changeUpdate(
-                  "insertChangesAndPatchSets",
-                  () -> {
-                    bu.execute();
-                    return null;
-                  })
-              .call();
-        } catch (Exception e) {
+          bu.execute();
+        } catch (UpdateException e) {
           throw asRestApiException(e);
         }
 
@@ -1002,6 +1013,11 @@
     }
   }
 
+  private boolean isReadyForReview(ChangeNotes changeNotes) {
+    return (!changeNotes.getChange().isWorkInProgress() && !magicBranch.workInProgress)
+        || magicBranch.ready;
+  }
+
   private String buildError(String error, List<String> branches) {
     StringBuilder sb = new StringBuilder();
     if (branches.size() == 1) {
@@ -1261,12 +1277,11 @@
       ProjectConfigEntry configEntry = e.getProvider().get();
       String value = pluginCfg.getString(e.getExportName());
       String oldValue =
-          projectState.getConfig().getPluginConfig(e.getPluginName()).getString(e.getExportName());
+          projectState.getPluginConfig(e.getPluginName()).getString(e.getExportName());
       if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
         oldValue =
             Arrays.stream(
                     projectState
-                        .getConfig()
                         .getPluginConfig(e.getPluginName())
                         .getStringList(e.getExportName()))
                 .collect(joining("\n"));
@@ -1818,7 +1833,9 @@
       magicBranch.perm = permissions.ref(ref);
 
       Optional<AuthException> err =
-          checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE);
+          checkRefPermission(magicBranch.perm, RefPermission.READ)
+              .map(Optional::of)
+              .orElse(checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE));
       if (err.isPresent()) {
         rejectProhibited(cmd, err.get());
         return;
@@ -2030,7 +2047,7 @@
       }
 
       if (magicBranch != null && magicBranch.shouldPublishComments()) {
-        List<Comment> drafts =
+        List<HumanComment> drafts =
             commentsUtil.draftByChangeAuthor(
                 notesFactory.createChecked(change), user.getAccountId());
         ImmutableList<CommentForValidation> draftsForValidation =
@@ -2069,7 +2086,7 @@
       } catch (IOException e) {
         throw new StorageException("Can't parse commit", e);
       }
-      List<String> idList = create.commit.getFooterLines(FooterConstants.CHANGE_ID);
+      List<String> idList = ChangeUtil.getChangeIdsFromFooter(create.commit, urlFormatter.get());
 
       if (idList.isEmpty()) {
         messages.add(
@@ -2161,7 +2178,7 @@
             }
           }
 
-          List<String> idList = c.getFooterLines(FooterConstants.CHANGE_ID);
+          List<String> idList = ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get());
           if (!idList.isEmpty()) {
             pending.put(c, lookupByChangeKey(c, Change.key(idList.get(idList.size() - 1).trim())));
           } else {
@@ -2195,6 +2212,7 @@
 
           BranchCommitValidator.Result validationResult =
               validator.validateCommit(
+                  repo,
                   receivePack.getRevWalk().getObjectReader(),
                   magicBranch.cmd,
                   c,
@@ -2673,12 +2691,10 @@
 
   private void readChangesForReplace() {
     try (TraceTimer traceTimer = newTimer("readChangesForReplace")) {
-      Collection<ChangeNotes> allNotes =
-          notesFactory.create(
-              replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
-      for (ChangeNotes notes : allNotes) {
-        replaceByChange.get(notes.getChangeId()).notes = notes;
-      }
+      replaceByChange.values().stream()
+          .map(r -> r.ontoChange)
+          .map(id -> notesFactory.create(repo, project.getNameKey(), id))
+          .forEach(notes -> replaceByChange.get(notes.getChangeId()).notes = notes);
     }
   }
 
@@ -3214,7 +3230,7 @@
 
           BranchCommitValidator.Result validationResult =
               validator.validateCommit(
-                  walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
+                  repo, walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
           messages.addAll(validationResult.messages());
           if (!validationResult.isValid()) {
             break;
@@ -3247,6 +3263,13 @@
                       ObjectInserter ins = repo.newObjectInserter();
                       ObjectReader reader = ins.newReader();
                       RevWalk rw = new RevWalk(reader)) {
+                    if (ObjectId.zeroId().equals(cmd.getOldId())) {
+                      // The user is creating a new branch. The branch can't contain any changes, so
+                      // auto-closing doesn't apply. Exiting here early to spare any further,
+                      // potentially expensive computation that loop over all commits.
+                      return null;
+                    }
+
                     bu.setRepository(repo, rw, ins);
                     // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
@@ -3256,9 +3279,7 @@
                     rw.reset();
                     rw.sort(RevSort.REVERSE);
                     rw.markStart(newTip);
-                    if (!ObjectId.zeroId().equals(cmd.getOldId())) {
-                      rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
-                    }
+                    rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
 
                     Map<Change.Key, ChangeData> changeDataByKey = null;
                     List<ReplaceRequest> replaceAndClose = new ArrayList<>();
@@ -3270,6 +3291,8 @@
                     for (RevCommit c; (c = rw.next()) != null; ) {
                       rw.parseBody(c);
 
+                      // Check if change refs point to this commit. Usually there are 0-1 change
+                      // refs pointing to this commit.
                       for (Ref ref :
                           receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
                         if (!PatchSet.isChangeRef(ref.getName())) {
@@ -3296,7 +3319,8 @@
                         }
                       }
 
-                      for (String changeId : c.getFooterLines(FooterConstants.CHANGE_ID)) {
+                      for (String changeId :
+                          ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get())) {
                         if (changeDataByKey == null) {
                           changeDataByKey =
                               retryHelper
@@ -3373,6 +3397,8 @@
         logger.atSevere().withCause(e).log("Can't insert patchset");
       } catch (UpdateException e) {
         logger.atSevere().withCause(e).log("Failed to auto-close changes");
+      } finally {
+        logger.atFine().log("Done auto-closing changes");
       }
     }
   }
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 0baecf5..ce62d7a 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
@@ -29,10 +28,10 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -41,11 +40,13 @@
 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.registration.DynamicItem;
 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.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.AddReviewersOp;
@@ -56,11 +57,13 @@
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -128,6 +131,9 @@
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
   private final ReviewerAdder reviewerAdder;
+  private final Change change;
+  private final MessageIdGenerator messageIdGenerator;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   private final ProjectState projectState;
   private final BranchNameKey dest;
@@ -140,7 +146,6 @@
   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<>();
@@ -172,6 +177,8 @@
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
       Change change,
+      MessageIdGenerator messageIdGenerator,
+      DynamicItem<UrlFormatter> urlFormatter,
       @Assisted ProjectState projectState,
       @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
@@ -197,6 +204,9 @@
     this.projectCache = projectCache;
     this.sendEmailExecutor = sendEmailExecutor;
     this.reviewerAdder = reviewerAdder;
+    this.change = change;
+    this.messageIdGenerator = messageIdGenerator;
+    this.urlFormatter = urlFormatter;
 
     this.projectState = projectState;
     this.dest = dest;
@@ -210,7 +220,6 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
-    this.change = change;
   }
 
   @Override
@@ -344,7 +353,7 @@
     return true;
   }
 
-  private static ImmutableList<AddReviewerInput> getReviewerInputs(
+  private ImmutableList<AddReviewerInput> getReviewerInputs(
       @Nullable MagicBranchInput magicBranch,
       MailRecipients fromFooters,
       Change change,
@@ -358,13 +367,15 @@
                     change,
                     psInfo.getCommitId(),
                     psInfo.getAuthor().getAccount(),
-                    NotifyHandling.NONE)),
+                    NotifyHandling.NONE,
+                    newPatchSet.uploader())),
             Streams.stream(
                 newAddReviewerInputFromCommitIdentity(
                     change,
                     psInfo.getCommitId(),
                     psInfo.getCommitter().getAccount(),
-                    NotifyHandling.NONE)));
+                    NotifyHandling.NONE,
+                    newPatchSet.uploader())));
     if (magicBranch != null) {
       inputs =
           Streams.concat(
@@ -477,7 +488,7 @@
     change.setStatus(Change.Status.NEW);
     change.setCurrentPatchSet(info);
 
-    List<String> idList = commit.getFooterLines(CHANGE_ID);
+    List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
     change.setKey(Change.key(idList.get(idList.size() - 1).trim()));
   }
 
@@ -516,25 +527,27 @@
     @Override
     public void run() {
       try {
-        ReplacePatchSetSender cm =
+        ReplacePatchSetSender emailSender =
             replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
-        cm.setFrom(ctx.getAccount().account().id());
-        cm.setPatchSet(newPatchSet, info);
-        cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
-        cm.setNotify(ctx.getNotify(notes.getChangeId()));
-        cm.addReviewers(
+        emailSender.setFrom(ctx.getAccount().account().id());
+        emailSender.setPatchSet(newPatchSet, info);
+        emailSender.setChangeMessage(msg.getMessage(), ctx.getWhen());
+        emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
+        emailSender.addReviewers(
             Streams.concat(
                     oldRecipients.getReviewers().stream(),
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId))
                 .collect(toImmutableSet()));
-        cm.addExtraCC(
+        emailSender.addExtraCC(
             Streams.concat(
                     oldRecipients.getCcOnly().stream(),
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs).stream())
                 .collect(toImmutableSet()));
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
         // TODO(dborowitz): Support byEmail
-        cm.send();
+        emailSender.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log(
             "Cannot send email for new patch set %s", newPatchSet.id());
diff --git a/java/com/google/gerrit/server/git/validators/CommentCountValidator.java b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
index 67aa3bd..a554f90 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
@@ -45,7 +45,7 @@
     ChangeNotes notes =
         notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId()));
     int numExistingCommentsAndChangeMessages =
-        notes.getComments().size()
+        notes.getHumanComments().size()
             + notes.getRobotComments().size()
             + notes.getChangeMessages().size();
     if (!comments.isEmpty()
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index d9a1420..d507531 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -51,7 +51,7 @@
         notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId()));
     int existingCumulativeSize =
         Stream.concat(
-                    notes.getComments().values().stream(),
+                    notes.getHumanComments().values().stream(),
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 7535f51..f84b696 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
@@ -45,6 +46,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.validators.ValidationMessage.Type;
+import com.google.gerrit.server.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.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
@@ -230,7 +234,17 @@
     List<CommitValidationMessage> messages = new ArrayList<>();
     try {
       for (CommitValidationListener commitValidator : validators) {
-        messages.addAll(commitValidator.onCommitReceived(receiveEvent));
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Running CommitValidationListener",
+                Metadata.builder()
+                    .className(commitValidator.getClass().getSimpleName())
+                    .projectName(receiveEvent.getProjectNameKey().get())
+                    .branchName(receiveEvent.getBranchNameKey().branch())
+                    .commit(receiveEvent.commit.name())
+                    .build())) {
+          messages.addAll(commitValidator.onCommitReceived(receiveEvent));
+        }
       }
     } catch (CommitValidationException e) {
       logger.atFine().withCause(e).log(
@@ -252,6 +266,12 @@
         "multiple Change-Id lines in message footer";
     private static final String INVALID_CHANGE_ID_MSG =
         "invalid Change-Id line format in message footer";
+    private static final String HTTP_INSTALL_HOOK =
+        "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\""
+            + " %stools/hooks/commit-msg ; chmod +x \"$f\"";
+    private static final String SSH_INSTALL_HOOK =
+        "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/";
+    private static final String CHANGE_ID_MISSING_INSTALL_HOOKS = "  %s\nor, for http(s):\n  %s";
 
     @VisibleForTesting
     public static final String CHANGE_ID_MISMATCH_MSG =
@@ -289,7 +309,7 @@
       }
       RevCommit commit = receiveEvent.commit;
       List<CommitValidationMessage> messages = new ArrayList<>();
-      List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
+      List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter);
 
       if (idList.isEmpty()) {
         String shortMsg = commit.getShortMessage();
@@ -358,14 +378,15 @@
       // If there are no SSH keys, the commit-msg hook must be installed via
       // HTTP(S)
       Optional<String> webUrl = urlFormatter.getWebUrl();
+
+      String httpHook = String.format(HTTP_INSTALL_HOOK, webUrl.get());
+
       if (hostKeys.isEmpty()) {
         checkState(webUrl.isPresent());
-        return String.format(
-            "  f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
-            webUrl.get());
+        return httpHook;
       }
 
-      // SSH keys exist, so the hook can be installed with scp.
+      // SSH keys exist, so the hook might be able to be installed with scp.
       String sshHost;
       int sshPort;
       String host = hostKeys.get(0).getHost();
@@ -383,9 +404,10 @@
         sshPort = 22;
       }
 
-      return String.format(
-          "  gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
-          sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
+      String sshHook =
+          String.format(
+              SSH_INSTALL_HOOK, sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
+      return String.format(CHANGE_ID_MISSING_INSTALL_HOOKS, sshHook, httpHook);
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index b47d7d6..79d53ac 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 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.project.ProjectState;
 import org.eclipse.jgit.lib.Repository;
 
@@ -33,6 +34,7 @@
    * Validate a commit before it is merged.
    *
    * @param repo the repository
+   * @param revWalk the rev walk
    * @param commit commit details
    * @param destProject the destination project
    * @param destBranch the destination branch
@@ -42,6 +44,7 @@
    */
   void onPreMerge(
       Repository repo,
+      CodeReviewRevWalk revWalk,
       CodeReviewCommit commit,
       ProjectState destProject,
       BranchNameKey destBranch,
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 04cbe36..cbaa121 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -47,10 +48,10 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
+import java.util.Objects;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 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
@@ -91,6 +92,7 @@
    */
   public void validatePreMerge(
       Repository repo,
+      CodeReviewRevWalk revWalk,
       CodeReviewCommit commit,
       ProjectState destProject,
       BranchNameKey destBranch,
@@ -105,7 +107,7 @@
             groupValidatorFactory.create());
 
     for (MergeValidationListener validator : validators) {
-      validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
+      validator.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller);
     }
   }
 
@@ -167,11 +169,12 @@
 
     @Override
     public void onPreMerge(
-        final Repository repo,
-        final CodeReviewCommit commit,
-        final ProjectState destProject,
-        final BranchNameKey destBranch,
-        final PatchSet.Id patchSetId,
+        Repository repo,
+        CodeReviewRevWalk revWalk,
+        CodeReviewCommit commit,
+        ProjectState destProject,
+        BranchNameKey destBranch,
+        PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
       if (RefNames.REFS_CONFIG.equals(destBranch.branch())) {
@@ -228,13 +231,9 @@
 
             String value = pluginCfg.getString(e.getExportName());
             String oldValue =
-                destProject
-                    .getConfig()
-                    .getPluginConfig(e.getPluginName())
-                    .getString(e.getExportName());
+                destProject.getPluginConfig(e.getPluginName()).getString(e.getExportName());
 
-            if ((value == null ? oldValue != null : !value.equals(oldValue))
-                && !configEntry.isEditable(destProject)) {
+            if ((!Objects.equals(value, oldValue)) && !configEntry.isEditable(destProject)) {
               throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
             }
 
@@ -263,6 +262,7 @@
     @Override
     public void onPreMerge(
         Repository repo,
+        CodeReviewRevWalk revWalk,
         CodeReviewCommit commit,
         ProjectState destProject,
         BranchNameKey destBranch,
@@ -270,7 +270,7 @@
         IdentifiedUser caller)
         throws MergeValidationException {
       mergeValidationListeners.runEach(
-          l -> l.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller),
+          l -> l.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller),
           MergeValidationException.class);
     }
   }
@@ -297,6 +297,7 @@
     @Override
     public void onPreMerge(
         Repository repo,
+        CodeReviewRevWalk revWalk,
         CodeReviewCommit commit,
         ProjectState destProject,
         BranchNameKey destBranch,
@@ -319,8 +320,9 @@
         throw new MergeValidationException("account validation unavailable");
       }
 
-      try (RevWalk rw = new RevWalk(repo)) {
-        List<String> errorMessages = accountValidator.validate(accountId, repo, rw, null, commit);
+      try {
+        List<String> errorMessages =
+            accountValidator.validate(accountId, repo, revWalk, null, commit);
         if (!errorMessages.isEmpty()) {
           throw new MergeValidationException(
               "invalid account configuration: " + Joiner.on("; ").join(errorMessages));
@@ -348,6 +350,7 @@
     @Override
     public void onPreMerge(
         Repository repo,
+        CodeReviewRevWalk revWalk,
         CodeReviewCommit commit,
         ProjectState destProject,
         BranchNameKey destBranch,
diff --git a/java/com/google/gerrit/server/git/validators/ValidationMessage.java b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
index faf29fe..b5d7eb1 100644
--- a/java/com/google/gerrit/server/git/validators/ValidationMessage.java
+++ b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.git.validators;
 
+import java.util.Objects;
+
 /**
  * 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 {
+    FATAL("FATAL: "),
     ERROR("ERROR: "),
     WARNING("WARNING: "),
     HINT("hint: "),
@@ -68,6 +71,25 @@
    * Returns {@true} if this message is an error. Used to decide if the operation should be aborted.
    */
   public boolean isError() {
-    return type == Type.ERROR;
+    return type == Type.FATAL || type == Type.ERROR;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(message, type);
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof ValidationMessage) {
+      ValidationMessage other = (ValidationMessage) obj;
+      return Objects.equals(message, other.message) && Objects.equals(type, other.type);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return getType() + ": " + getMessage();
   }
 }
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 1aa265b..50ec893 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -15,9 +15,9 @@
 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;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
diff --git a/java/com/google/gerrit/server/group/GroupResource.java b/java/com/google/gerrit/server/group/GroupResource.java
index 1050314..b0e81ec 100644
--- a/java/com/google/gerrit/server/group/GroupResource.java
+++ b/java/com/google/gerrit/server/group/GroupResource.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.account.GroupControl;
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
index c70c8bf..740557a 100644
--- a/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -19,9 +19,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import java.sql.Timestamp;
 
 public class InternalGroupDescription implements GroupDescription.Internal {
diff --git a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
index b2d9632..cae213f 100644
--- a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
+++ b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
@@ -19,8 +19,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.AllUsersName;
diff --git a/java/com/google/gerrit/server/group/SubgroupResource.java b/java/com/google/gerrit/server/group/SubgroupResource.java
index ceea2dc..21356be 100644
--- a/java/com/google/gerrit/server/group/SubgroupResource.java
+++ b/java/com/google/gerrit/server/group/SubgroupResource.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
 
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 7821a01..b5ccb18 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -21,9 +21,9 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
@@ -104,7 +104,7 @@
       reservedNamesBuilder.add(defaultName);
       String configuredName = cfg.getString("groups", uuid.get(), "name");
       GroupReference ref =
-          new GroupReference(uuid, MoreObjects.firstNonNull(configuredName, defaultName));
+          GroupReference.create(uuid, MoreObjects.firstNonNull(configuredName, defaultName));
       n.put(ref.getName().toLowerCase(Locale.US), ref);
       u.put(ref.getUUID(), ref);
     }
diff --git a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
index ec4c0fc..235ca4f 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
@@ -19,9 +19,9 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index 70d7a1a..cdba81f 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -28,8 +28,8 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
@@ -443,7 +443,7 @@
       throw new ConfigInvalidException(String.format("UUID for group '%s' must be defined", name));
     }
 
-    return new GroupReference(AccountGroup.uuid(uuid), name);
+    return GroupReference.create(AccountGroup.uuid(uuid), name);
   }
 
   private String getCommitMessage() {
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 163b9c6..90a5a1f 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -17,10 +17,10 @@
 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;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.InternalGroup;
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index 0414304..35f5dea 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -23,8 +23,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
diff --git a/java/com/google/gerrit/server/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index 2f24233..257bc16 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -17,8 +17,9 @@
 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;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.DefaultQueueOp;
 import com.google.gerrit.server.git.WorkQueue;
@@ -30,6 +31,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -87,10 +89,10 @@
   public void run() {
     Iterable<Project.NameKey> names = tryingAgain ? retryOn : projectCache.all();
     for (Project.NameKey projectName : names) {
-      ProjectConfig config =
+      CachedProjectConfig config =
           projectCache.get(projectName).orElseThrow(illegalState(projectName)).getConfig();
-      GroupReference ref = config.getGroup(uuid);
-      if (ref == null || newName.equals(ref.getName())) {
+      Optional<GroupReference> ref = config.getGroup(uuid);
+      if (!ref.isPresent() || newName.equals(ref.get().getName())) {
         continue;
       }
 
@@ -125,7 +127,7 @@
         return;
       }
 
-      ref.setName(newName);
+      config.renameGroup(uuid, newName);
       md.getCommitBuilder().setAuthor(author);
       md.setMessage("Rename group " + oldName + " to " + newName + "\n");
       try {
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 51c7ca3..35f18a2 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -18,9 +18,10 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
@@ -34,6 +35,7 @@
   private static final String PREFIX = "testbackend:";
 
   private final Map<AccountGroup.UUID, GroupDescription.Basic> groups = new HashMap<>();
+  private final Map<Account.Id, GroupMembership> memberships = new HashMap<>();
 
   /**
    * Create a group by name.
@@ -92,6 +94,14 @@
     groups.remove(uuid);
   }
 
+  /**
+   * Makes this backend return the specified {@link GroupMembership} when being asked for the
+   * specified {@link com.google.gerrit.entities.Account.Id}.
+   */
+  public void setMembershipsOf(Account.Id user, GroupMembership membership) {
+    memberships.put(user, membership);
+  }
+
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
     if (uuid != null) {
@@ -113,7 +123,7 @@
 
   @Override
   public GroupMembership membershipsOf(IdentifiedUser user) {
-    return GroupMembership.EMPTY;
+    return memberships.getOrDefault(user.getAccountId(), GroupMembership.EMPTY);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 494aa84..d6b8ef9 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.index.change;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.util.concurrent.Futures.successfulAsList;
 import static com.google.common.util.concurrent.Futures.transform;
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
@@ -22,9 +21,8 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Stopwatch;
-import com.google.common.collect.Sets;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.UncheckedExecutionException;
@@ -34,6 +32,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.git.MultiProgressMonitor.VolatileTask;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.OnlineReindexMode;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -44,18 +43,13 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Set;
 import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
-import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ProgressMonitor;
 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
@@ -64,6 +58,9 @@
  */
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private MultiProgressMonitor mpm;
+  private VolatileTask doneTask;
+  private Task failedTask;
   private static final int PROJECT_SLICE_MAX_REFS = 1000;
 
   private static class ProjectsCollectionFailure extends Exception {
@@ -130,55 +127,18 @@
     // in 2020.
 
     Stopwatch sw = Stopwatch.createStarted();
-    List<ProjectSlice> projectSlices;
+    AtomicBoolean ok = new AtomicBoolean(true);
+    mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
+    doneTask = mpm.beginVolatileSubTask("changes");
+    failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
+    List<ListenableFuture<?>> futures;
     try {
-      projectSlices = new SliceCreator().create();
-    } catch (ProjectsCollectionFailure | InterruptedException | ExecutionException e) {
+      futures = new SliceScheduler(index, ok).schedule();
+    } catch (ProjectsCollectionFailure e) {
       logger.atSevere().log(e.getMessage());
       return Result.create(sw, false, 0, 0);
     }
 
-    // Since project slices are created in parallel, they are somewhat shuffled already. However,
-    // the number of threads used to create the project slices doesn't guarantee good randomization.
-    // If the slices are not shuffled well, then multiple threads would typically work concurrently
-    // on different slices of the same project. While this is not a big issue, shuffling the list
-    // beforehand helps with ungrouping the project slices, so different slices are less likely to
-    // be worked on concurrently.
-    // This shuffling gave a 6% runtime reduction for Wikimedia's Gerrit in 2020.
-    Collections.shuffle(projectSlices);
-    return indexAll(index, projectSlices);
-  }
-
-  private SiteIndexer.Result indexAll(ChangeIndex index, List<ProjectSlice> projectSlices) {
-    Stopwatch sw = Stopwatch.createStarted();
-    MultiProgressMonitor mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
-    Task projTask = mpm.beginSubTask("project-slices", projectSlices.size());
-    checkState(totalWork >= 0);
-    Task doneTask = mpm.beginSubTask(null, totalWork);
-    Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
-
-    List<ListenableFuture<?>> futures = new ArrayList<>();
-    AtomicBoolean ok = new AtomicBoolean(true);
-
-    for (ProjectSlice projectSlice : projectSlices) {
-      Project.NameKey name = projectSlice.name();
-      int slice = projectSlice.slice();
-      int slices = projectSlice.slices();
-      ListenableFuture<?> future =
-          executor.submit(
-              reindexProject(
-                  indexerFactory.create(executor, index),
-                  name,
-                  slice,
-                  slices,
-                  projectSlice.scanResult(),
-                  doneTask,
-                  failedTask));
-      String description = "project " + name + " (" + slice + "/" + slices + ")";
-      addErrorListener(future, description, projTask, ok);
-      futures.add(future);
-    }
-
     try {
       mpm.waitFor(
           transform(
@@ -289,7 +249,7 @@
       }
     }
 
-    private void fail(String error, boolean failed, Exception e) {
+    private void fail(String error, boolean failed, Throwable e) {
       if (failed) {
         this.failed.update(1);
       }
@@ -308,30 +268,53 @@
     }
   }
 
-  private class SliceCreator {
-    final Set<ProjectSlice> projectSlices = Sets.newConcurrentHashSet();
+  private class SliceScheduler {
+    final ChangeIndex index;
+    final AtomicBoolean ok;
     final AtomicInteger changeCount = new AtomicInteger(0);
     final AtomicInteger projectsFailed = new AtomicInteger(0);
-    final ProgressMonitor pm = new TextProgressMonitor();
+    final List<ListenableFuture<?>> sliceIndexerFutures = new ArrayList<>();
+    final List<ListenableFuture<?>> sliceCreationFutures = new ArrayList<>();
+    VolatileTask projTask = mpm.beginVolatileSubTask("project-slices");
+    Task slicingProjects;
 
-    private List<ProjectSlice> create()
-        throws ProjectsCollectionFailure, InterruptedException, ExecutionException {
-      List<ListenableFuture<?>> futures = new ArrayList<>();
-      pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
-      for (Project.NameKey name : projectCache.all()) {
-        futures.add(executor.submit(new ProjectSliceCreator(name)));
+    public SliceScheduler(ChangeIndex index, AtomicBoolean ok) {
+      this.index = index;
+      this.ok = ok;
+    }
+
+    private List<ListenableFuture<?>> schedule() throws ProjectsCollectionFailure {
+      ImmutableSortedSet<Project.NameKey> projects = projectCache.all();
+      int projectCount = projects.size();
+      slicingProjects = mpm.beginSubTask("Slicing projects", projectCount);
+      for (Project.NameKey name : projects) {
+        sliceCreationFutures.add(executor.submit(new ProjectSliceCreator(name)));
       }
 
-      Futures.allAsList(futures).get();
+      try {
+        mpm.waitForNonFinalTask(
+            transform(
+                successfulAsList(sliceCreationFutures),
+                x -> {
+                  projTask.finalizeTotal();
+                  doneTask.finalizeTotal();
+                  return null;
+                },
+                directExecutor()));
+      } catch (UncheckedExecutionException e) {
+        logger.atSevere().withCause(e).log("Error project slice creation");
+        ok.set(false);
+      }
 
-      if (projectsFailed.get() > projectCache.all().size() / 2) {
+      if (projectsFailed.get() > projectCount / 2) {
         throw new ProjectsCollectionFailure(
             "Over 50%% of the projects could not be collected: aborted");
       }
 
-      pm.endTask();
+      slicingProjects.endTask();
       setTotalWork(changeCount.get());
-      return projectSlices.stream().collect(Collectors.toList());
+
+      return sliceIndexerFutures;
     }
 
     private class ProjectSliceCreator implements Callable<Void> {
@@ -353,15 +336,32 @@
               verboseWriter.println(
                   "Submitting " + name + " for indexing in " + slices + " slices");
             }
+
+            doneTask.updateTotal(size);
+            projTask.updateTotal(slices);
+
             for (int slice = 0; slice < slices; slice++) {
-              projectSlices.add(ProjectSlice.create(name, slice, slices, sr));
+              ProjectSlice projectSlice = ProjectSlice.create(name, slice, slices, sr);
+              ListenableFuture<?> future =
+                  executor.submit(
+                      reindexProject(
+                          indexerFactory.create(executor, index),
+                          name,
+                          slice,
+                          slices,
+                          projectSlice.scanResult(),
+                          doneTask,
+                          failedTask));
+              String description = "project " + name + " (" + slice + "/" + slices + ")";
+              addErrorListener(future, description, projTask, ok);
+              sliceIndexerFutures.add(future);
             }
           }
         } catch (IOException e) {
           logger.atSevere().withCause(e).log("Error collecting project %s", name);
           projectsFailed.incrementAndGet();
         }
-        pm.update(1);
+        slicingProjects.update(1);
         return null;
       }
     }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index a7c4016..ef538cb 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -42,15 +42,16 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
 import com.google.common.primitives.Longs;
-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.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -59,7 +60,6 @@
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index a1c6286..7e50104 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -26,7 +26,6 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
@@ -201,7 +200,8 @@
 
       // Quote everything except the '*'s, which become ".*".
       String regex =
-          Streams.stream(Splitter.on('*').split(pattern))
+          Splitter.on('*')
+              .splitToStream(pattern)
               .map(Pattern::quote)
               .collect(joining(".*", "^", "$"));
       return new AutoValue_StalenessChecker_RefStatePattern(
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 84d6a9d..fcf0d6d 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -22,8 +22,8 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index 7af34f7..c60af0d 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -3,7 +3,7 @@
 java_library(
     name = "logging",
     srcs = glob(
-        ["**/*.java"],
+        ["*.java"],
     ),
     visibility = ["//visibility:public"],
     deps = [
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 8e786fc..ac4df7b 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -42,6 +42,15 @@
   private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+
+  /**
+   * When copying the logging context to a new thread we need to ensure that the performance log
+   * records that are added in the new thread are added to the same {@link
+   * MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * LoggingContextAwareCallable}). This is important since performance log records are processed
+   * only at the end of the request and performance log records that are created in another thread
+   * should not get lost.
+   */
   private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
       new ThreadLocal<>();
 
@@ -57,11 +66,6 @@
       return runnable;
     }
 
-    // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareRunnable
-    // constructor so that performance log records that are created in the wrapped runnable are
-    // added to this MutablePerformanceLogRecords instance. This is important since performance
-    // log records are processed only at the end of the request and performance log records that
-    // are created in another thread should not get lost.
     return new LoggingContextAwareRunnable(
         runnable, getInstance().getMutablePerformanceLogRecords());
   }
@@ -71,11 +75,6 @@
       return callable;
     }
 
-    // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareCallable
-    // constructor so that performance log records that are created in the wrapped runnable are
-    // added to this MutablePerformanceLogRecords instance. This is important since performance
-    // log records are processed only at the end of the request and performance log records that
-    // are created in another thread should not get lost.
     return new LoggingContextAwareCallable<>(
         callable, getInstance().getMutablePerformanceLogRecords());
   }
@@ -230,12 +229,7 @@
    * <p><strong>Attention:</strong> The passed in {@link MutablePerformanceLogRecords} instance is
    * directly stored in the logging context.
    *
-   * <p>This method is intended to be only used when the logging context is copied to a new thread
-   * to ensure that the performance log records that are added in the new thread are added to the
-   * same {@link MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and
-   * {@link LoggingContextAwareCallable}). This is important since performance log records are
-   * processed only at the end of the request and performance log records that are created in
-   * another thread should not get lost.
+   * <p>This method is intended to be only used when the logging context is copied to a new thread.
    *
    * @param mutablePerformanceLogRecords the {@link MutablePerformanceLogRecords} instance in which
    *     performance log records should be stored
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index d2701d7..1adee1b 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -75,13 +75,6 @@
     loggingCtx.setTags(tags);
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
-
-    // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
-    // the logging context of the calling thread in the logging context of the new thread. This way
-    // performance log records that are created from the new thread are available from the logging
-    // context of the calling thread. This is important since performance log records are processed
-    // only at the end of the request and performance log records that are created in another thread
-    // should not get lost.
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
     try {
       return callable.call();
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index 23162b1..d0559cc 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -98,13 +98,6 @@
     loggingCtx.setTags(tags);
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
-
-    // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
-    // the logging context of the calling thread in the logging context of the new thread. This way
-    // performance log records that are created from the new thread are available from the logging
-    // context of the calling thread. This is important since performance log records are processed
-    // only at the end of the request and performance log records that are created in another thread
-    // should not get lost.
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
     try {
       runnable.run();
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 3bb4770..1a4a335 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -31,116 +31,126 @@
 /** Metadata that is provided to {@link PerformanceLogger}s as context for performance records. */
 @AutoValue
 public abstract class Metadata {
-  // The numeric ID of an account.
+  /** The numeric ID of an account. */
   public abstract Optional<Integer> accountId();
 
-  // The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
-  // PLUGIN_UPDATE).
+  /**
+   * The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
+   * PLUGIN_UPDATE).
+   */
   public abstract Optional<String> actionType();
 
-  // An authentication domain name.
+  /** An authentication domain name. */
   public abstract Optional<String> authDomainName();
 
-  // The name of a branch.
+  /** The name of a branch. */
   public abstract Optional<String> branchName();
 
-  // Key of an entity in a cache.
+  /** Key of an entity in a cache. */
   public abstract Optional<String> cacheKey();
 
-  // The name of a cache.
+  /** The name of a cache. */
   public abstract Optional<String> cacheName();
 
-  // The name of the implementation class.
+  /** The name of the implementation class. */
   public abstract Optional<String> className();
 
-  // The numeric ID of a change.
+  /** The numeric ID of a change. */
   public abstract Optional<Integer> changeId();
 
-  // The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
+  /**
+   * 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.
+  /** The cause of an error. */
   public abstract Optional<String> cause();
 
-  // The type of an event.
+  /** The SHA1 of a commit. */
+  public abstract Optional<String> commit();
+
+  /** The type of an event. */
   public abstract Optional<String> eventType();
 
-  // The value of the @Export annotation which was used to register a plugin extension.
+  /** The value of the @Export annotation which was used to register a plugin extension. */
   public abstract Optional<String> exportValue();
 
-  // Path of a file in a repository.
+  /** Path of a file in a repository. */
   public abstract Optional<String> filePath();
 
-  // Garbage collector name.
+  /** Garbage collector name. */
   public abstract Optional<String> garbageCollectorName();
 
-  // Git operation (CLONE, FETCH).
+  /** Git operation (CLONE, FETCH). */
   public abstract Optional<String> gitOperation();
 
-  // The numeric ID of an internal group.
+  /** The numeric ID of an internal group. */
   public abstract Optional<Integer> groupId();
 
-  // The name of a group.
+  /** The name of a group. */
   public abstract Optional<String> groupName();
 
-  // The UUID of a group.
+  /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
-  // HTTP status response code.
+  /** HTTP status response code. */
   public abstract Optional<Integer> httpStatus();
 
-  // The name of a secondary index.
+  /** The name of a secondary index. */
   public abstract Optional<String> indexName();
 
-  // The version of a secondary index.
+  /** The version of a secondary index. */
   public abstract Optional<Integer> indexVersion();
 
-  // The name of the implementation method.
+  /** The name of the implementation method. */
   public abstract Optional<String> methodName();
 
-  // One or more resources
+  /** One or more resources */
   public abstract Optional<Boolean> multiple();
 
-  // The name of an operation that is performed.
+  /** The name of an operation that is performed. */
   public abstract Optional<String> operationName();
 
-  // Partial or full computation
+  /** Partial or full computation */
   public abstract Optional<Boolean> partial();
 
-  // Path of a metadata file in NoteDb.
+  /** If a value is still current or not */
+  public abstract Optional<Boolean> outdated();
+
+  /** Path of a metadata file in NoteDb. */
   public abstract Optional<String> noteDbFilePath();
 
-  // Name of a metadata ref in NoteDb.
+  /** Name of a metadata ref in NoteDb. */
   public abstract Optional<String> noteDbRefName();
 
-  // Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS).
+  /** Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS). */
   public abstract Optional<String> noteDbSequenceType();
 
-  // The ID of a patch set.
+  /** The ID of a patch set. */
   public abstract Optional<Integer> patchSetId();
 
-  // Plugin metadata that doesn't fit into any other category.
+  /** Plugin metadata that doesn't fit into any other category. */
   public abstract ImmutableList<PluginMetadata> pluginMetadata();
 
-  // The name of a plugin.
+  /** The name of a plugin. */
   public abstract Optional<String> pluginName();
 
-  // The name of a Gerrit project (aka Git repository).
+  /** The name of a Gerrit project (aka Git repository). */
   public abstract Optional<String> projectName();
 
-  // The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE).
+  /** The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE). */
   public abstract Optional<String> pushType();
 
-  // The number of resources that is processed.
+  /** The number of resources that is processed. */
   public abstract Optional<Integer> resourceCount();
 
-  // The name of a REST view.
+  /** The name of a REST view. */
   public abstract Optional<String> restViewName();
 
-  // The SHA1 of Git commit.
+  /** The SHA1 of Git commit. */
   public abstract Optional<String> revision();
 
-  // The username of an account.
+  /** The username of an account. */
   public abstract Optional<String> username();
 
   /**
@@ -275,6 +285,8 @@
 
     public abstract Builder cause(@Nullable String cause);
 
+    public abstract Builder commit(@Nullable String commit);
+
     public abstract Builder eventType(@Nullable String eventType);
 
     public abstract Builder exportValue(@Nullable String exportValue);
@@ -305,6 +317,8 @@
 
     public abstract Builder partial(boolean partial);
 
+    public abstract Builder outdated(boolean outdated);
+
     public abstract Builder noteDbFilePath(@Nullable String noteDbFilePath);
 
     public abstract Builder noteDbRefName(@Nullable String noteDbRefName);
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index cc3db75..ff166b1 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.mail.send.AbandonedSender;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
 import com.google.gerrit.server.mail.send.DeleteKeySender;
@@ -26,6 +27,7 @@
 import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
 import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.mail.send.RevertedSender;
@@ -49,5 +51,7 @@
     factory(RestoredSender.Factory.class);
     factory(RevertedSender.Factory.class);
     factory(SetAssigneeSender.Factory.class);
+    factory(AddToAttentionSetSender.Factory.class);
+    factory(RemoveFromAttentionSetSender.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/EmailSettings.java b/java/com/google/gerrit/server/mail/EmailSettings.java
index 6bdb076..15b61d0 100644
--- a/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -37,6 +37,8 @@
   public final String password;
   public final Encryption encryption;
   public final long fetchInterval; // in milliseconds
+  public final boolean sendNewPatchsetEmails;
+  public final boolean isAttentionSetEnabled;
 
   @Inject
   EmailSettings(@GerritServerConfig Config cfg) {
@@ -58,5 +60,7 @@
             "fetchInterval",
             TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
+    sendNewPatchsetEmails = cfg.getBoolean("change", null, "sendNewPatchsetEmails", true);
+    isAttentionSetEnabled = cfg.getBoolean("change", null, "enableAttentionSet", true);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/ListMailFilter.java b/java/com/google/gerrit/server/mail/ListMailFilter.java
index 23f7e12..67cef45 100644
--- a/java/com/google/gerrit/server/mail/ListMailFilter.java
+++ b/java/com/google/gerrit/server/mail/ListMailFilter.java
@@ -31,8 +31,8 @@
 
   public enum ListFilterMode {
     OFF,
-    WHITELIST,
-    BLACKLIST
+    ALLOW,
+    BLOCK
   }
 
   private final ListFilterMode mode;
@@ -40,12 +40,37 @@
 
   @Inject
   ListMailFilter(@GerritServerConfig Config cfg) {
-    this.mode = cfg.getEnum("receiveemail", "filter", "mode", ListFilterMode.OFF);
+    mode = getListFilterMode(cfg);
     String[] addresses = cfg.getStringList("receiveemail", "filter", "patterns");
     String concat = Arrays.asList(addresses).stream().collect(joining("|"));
     this.mailPattern = Pattern.compile(concat);
   }
 
+  private static final String LEGACY_ALLOW = "WHITELIST";
+  private static final String LEGACY_BLOCK = "BLACKLIST";
+
+  /** Legacy names are supported, but should be removed in the future. */
+  private ListFilterMode getListFilterMode(Config cfg) {
+    ListFilterMode mode;
+    String modeString = cfg.getString("receiveemail", "filter", "mode");
+    if (modeString == null) {
+      modeString = "";
+    }
+    switch (modeString) {
+      case LEGACY_ALLOW:
+      case "ALLOW":
+        mode = ListFilterMode.ALLOW;
+        break;
+      case LEGACY_BLOCK:
+      case "BLOCK":
+        mode = ListFilterMode.BLOCK;
+        break;
+      default:
+        mode = ListFilterMode.OFF;
+    }
+    return mode;
+  }
+
   @Override
   public boolean shouldProcessMessage(MailMessage message) {
     if (mode == ListFilterMode.OFF) {
@@ -53,8 +78,7 @@
     }
 
     boolean match = mailPattern.matcher(message.from().email()).find();
-    if ((mode == ListFilterMode.WHITELIST && !match)
-        || (mode == ListFilterMode.BLACKLIST && match)) {
+    if ((mode == ListFilterMode.ALLOW && !match) || (mode == ListFilterMode.BLOCK && match)) {
       logger.atInfo().log("Mail message from %s rejected by list filter", message.from());
       return false;
     }
diff --git a/java/com/google/gerrit/server/mail/MailUtil.java b/java/com/google/gerrit/server/mail/MailUtil.java
index ff22d23..aee8209 100644
--- a/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/java/com/google/gerrit/server/mail/MailUtil.java
@@ -72,7 +72,7 @@
   @SuppressWarnings("deprecation")
   private static Account.Id toAccountId(AccountResolver accountResolver, String nameOrEmail)
       throws UnprocessableEntityException, IOException, ConfigInvalidException {
-    return accountResolver.resolveByNameOrEmail(nameOrEmail).asUnique().account().id();
+    return accountResolver.resolveByExactNameOrEmail(nameOrEmail).asUnique().account().id();
   }
 
   private static boolean isReviewer(FooterLine candidateFooterLine) {
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 9c3dd02..df38118 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -24,7 +24,7 @@
 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.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -57,9 +57,8 @@
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -105,7 +104,6 @@
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
   private final OneOffRequestContext oneOffRequestContext;
-  private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
   private final Provider<InternalChangeQuery> queryProvider;
   private final DynamicMap<MailFilter> mailFilters;
@@ -115,6 +113,7 @@
   private final AccountCache accountCache;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final PluginSetContext<CommentValidator> commentValidators;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   public MailProcessor(
@@ -124,7 +123,6 @@
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
       OneOffRequestContext oneOffRequestContext,
-      PatchListCache patchListCache,
       PatchSetUtil psUtil,
       Provider<InternalChangeQuery> queryProvider,
       DynamicMap<MailFilter> mailFilters,
@@ -133,14 +131,14 @@
       CommentAdded commentAdded,
       AccountCache accountCache,
       DynamicItem<UrlFormatter> urlFormatter,
-      PluginSetContext<CommentValidator> commentValidators) {
+      PluginSetContext<CommentValidator> commentValidators,
+      MessageIdGenerator messageIdGenerator) {
     this.emails = emails;
     this.emailRejectionSender = emailRejectionSender;
     this.retryHelper = retryHelper;
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
     this.oneOffRequestContext = oneOffRequestContext;
-    this.patchListCache = patchListCache;
     this.psUtil = psUtil;
     this.queryProvider = queryProvider;
     this.mailFilters = mailFilters;
@@ -150,6 +148,7 @@
     this.accountCache = accountCache;
     this.urlFormatter = urlFormatter;
     this.commentValidators = commentValidators;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   /**
@@ -220,9 +219,10 @@
 
   private void sendRejectionEmail(MailMessage message, InboundEmailRejectionSender.Error reason) {
     try {
-      InboundEmailRejectionSender em =
+      InboundEmailRejectionSender emailSender =
           emailRejectionSender.create(message.from(), message.id(), reason);
-      em.send();
+      emailSender.setMessageId(messageIdGenerator.fromMailMessage(message));
+      emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot send email to warn for an error");
     }
@@ -252,7 +252,7 @@
       // Get all comments; filter and sort them to get the original list of
       // comments from the outbound email.
       // TODO(hiesel) Also filter by original comment author.
-      Collection<Comment> comments =
+      Collection<HumanComment> comments =
           cd.publishedComments().stream()
               .filter(c -> (c.writtenOn.getTime() / 1000) == (metadata.timestamp.getTime() / 1000))
               .sorted(CommentsUtil.COMMENT_ORDER)
@@ -314,7 +314,7 @@
     private final List<MailComment> parsedComments;
     private final String tag;
     private ChangeMessage changeMessage;
-    private List<Comment> comments;
+    private List<HumanComment> comments;
     private PatchSet patchSet;
     private ChangeNotes notes;
 
@@ -325,8 +325,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws UnprocessableEntityException, PatchListNotAvailableException {
+    public boolean updateChange(ChangeContext ctx) throws UnprocessableEntityException {
       patchSet = psUtil.get(ctx.getNotes(), psId);
       notes = ctx.getNotes();
       if (patchSet == null) {
@@ -344,8 +343,10 @@
         comments.add(
             persistentCommentFromMailComment(ctx, c, targetPatchSetForComment(ctx, c, patchSet)));
       }
-      commentsUtil.putComments(
-          ctx.getUpdate(ctx.getChange().currentPatchSetId()), Comment.Status.PUBLISHED, comments);
+      commentsUtil.putHumanComments(
+          ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+          HumanComment.Status.PUBLISHED,
+          comments);
 
       return true;
     }
@@ -366,7 +367,8 @@
               changeMessage,
               comments,
               patchSetComment,
-              ImmutableList.of())
+              ImmutableList.of(),
+              ctx.getRepoView())
           .sendAsync();
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
@@ -410,9 +412,8 @@
       return current;
     }
 
-    private Comment persistentCommentFromMailComment(
-        ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment)
-        throws UnprocessableEntityException, PatchListNotAvailableException {
+    private HumanComment persistentCommentFromMailComment(
+        ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment) {
       String fileName;
       // The patch set that this comment is based on is different if this
       // comment was sent in reply to a comment on a previous patch set.
@@ -425,9 +426,11 @@
         side = Side.REVISION;
       }
 
-      Comment comment =
-          commentsUtil.newComment(
-              ctx,
+      HumanComment comment =
+          commentsUtil.newHumanComment(
+              ctx.getNotes(),
+              ctx.getUser(),
+              ctx.getWhen(),
               fileName,
               patchSetForComment.id(),
               (short) side.ordinal(),
@@ -442,7 +445,7 @@
         comment.range = mailComment.getInReplyTo().range;
         comment.unresolved = mailComment.getInReplyTo().unresolved;
       }
-      CommentsUtil.setCommentCommitId(comment, patchListCache, ctx.getChange(), patchSetForComment);
+      commentsUtil.setCommentCommitId(comment, ctx.getChange(), patchSetForComment);
       return comment;
     }
   }
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
index a6fb4de..3ac610d 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -51,9 +51,4 @@
       appendHtml(soyHtmlTemplate("AbandonedHtml"));
     }
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 572c972..652766a 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -34,11 +34,16 @@
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
   private final List<String> gpgKeys;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted AccountSshKey sshKey) {
     super(args, "addkey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.sshKey = sshKey;
     this.gpgKeys = null;
@@ -46,8 +51,12 @@
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted List<String> gpgKeys) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted List<String> gpgKeys) {
     super(args, "addkey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.sshKey = null;
     this.gpgKeys = gpgKeys;
@@ -57,6 +66,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
+    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
     add(RecipientType.TO, user.getAccountId());
   }
 
@@ -88,11 +98,6 @@
     soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
   }
 
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
-
   private String getEmail() {
     return user.getAccount().preferredEmail();
   }
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
new file mode 100644
index 0000000..b13bcf6
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.mail.send;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Let users know of a new user in the attention set. */
+public class AddToAttentionSetSender extends AttentionSetSender {
+
+  public interface Factory extends ReplyToChangeSender.Factory<AddToAttentionSetSender> {
+    @Override
+    AddToAttentionSetSender create(Project.NameKey project, Change.Id changeId);
+  }
+
+  @Inject
+  public AddToAttentionSetSender(
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, project, changeId);
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("AddToAttentionSet"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("AddToAttentionSetHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
new file mode 100644
index 0000000..8f898a8
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.mail.send;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.EmailException;
+
+/** Base class for Attention Set email senders */
+public abstract class AttentionSetSender extends ReplyToChangeSender {
+  private Account.Id attentionSetUser;
+  private String reason;
+
+  public AttentionSetSender(EmailArguments args, Project.NameKey project, Change.Id changeId) {
+    super(args, "addToAttentionSet", ChangeEmail.newChangeData(args, project, changeId));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    ccExistingReviewers();
+    removeUsersThatIgnoredTheChange();
+  }
+
+  public void setAttentionSetUser(Account.Id attentionSetUser) {
+    this.attentionSetUser = attentionSetUser;
+  }
+
+  public void setReason(String reason) {
+    this.reason = reason;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContext.put("attentionSetUser", getNameFor(attentionSetUser));
+    soyContext.put("reason", reason);
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 22d332a..7890d35 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -22,6 +25,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -30,10 +34,11 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
@@ -54,8 +59,10 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.stream.Collectors;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
@@ -72,6 +79,7 @@
     return ea.changeDataFactory.create(project, id);
   }
 
+  private final Set<Account.Id> currentAttentionSet;
   protected final Change change;
   protected final ChangeData changeData;
   protected ListMultimap<Account.Id, String> stars;
@@ -89,6 +97,7 @@
     this.changeData = changeData;
     this.change = changeData.change();
     this.emailOnlyAuthors = false;
+    this.currentAttentionSet = getAttentionSet();
   }
 
   @Override
@@ -120,6 +129,10 @@
   /** Format the message body by calling {@link #appendText(String)}. */
   @Override
   protected void format() throws EmailException {
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
+    }
+    appendText(textTemplate("ChangeHeader"));
     formatChange();
     appendText(textTemplate("ChangeFooter"));
     if (useHtml()) {
@@ -384,9 +397,29 @@
 
   @Override
   protected void add(RecipientType rt, Account.Id to) {
-    if (!emailOnlyAuthors || authors.contains(to)) {
-      super.add(rt, to);
+    addRecipient(rt, to, /* isWatcher= */ false);
+  }
+
+  /** This bypasses the EmailStrategy.ATTENTION_SET_ONLY strategy when adding the recipient. */
+  @Override
+  protected void addWatcher(RecipientType rt, Account.Id to) {
+    addRecipient(rt, to, /* isWatcher= */ true);
+  }
+
+  private void addRecipient(RecipientType rt, Account.Id to, boolean isWatcher) {
+    if (!isWatcher) {
+      Optional<AccountState> accountState = args.accountCache.get(to);
+      if (accountState.isPresent()
+          && accountState.get().generalPreferences().getEmailStrategy()
+              == EmailStrategy.ATTENTION_SET_ONLY
+          && !currentAttentionSet.contains(to)) {
+        return;
+      }
     }
+    if (emailOnlyAuthors && !authors.contains(to)) {
+      return;
+    }
+    super.add(rt, to);
   }
 
   @Override
@@ -484,6 +517,16 @@
     for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
       footers.add(MailHeader.CC.withDelimiter() + reviewer);
     }
+    for (Account.Id attentionUser : currentAttentionSet) {
+      footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
+    }
+    // Since this would be user visible, only show it if attention set is enabled
+    if (args.settings.isAttentionSetEnabled && !currentAttentionSet.isEmpty()) {
+      // We need names rather than account ids / emails to make it user readable.
+      soyContext.put(
+          "attentionSet",
+          currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
+    }
   }
 
   /**
@@ -509,6 +552,19 @@
     return reviewers;
   }
 
+  private Set<Account.Id> getAttentionSet() {
+    Set<Account.Id> attentionSet = new TreeSet<>();
+    try {
+      attentionSet =
+          additionsOnly(changeData.attentionSet()).stream()
+              .map(a -> a.account())
+              .collect(Collectors.toSet());
+    } catch (StorageException e) {
+      logger.atWarning().withCause(e).log("Cannot get change attention set");
+    }
+    return attentionSet;
+  }
+
   public boolean getIncludeDiff() {
     return args.settings.includeDiff;
   }
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 48d342e..ac6c2f3 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -22,7 +22,8 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.KeyUtil;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
@@ -33,7 +34,6 @@
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
 import com.google.gerrit.server.patch.PatchFile;
@@ -72,20 +72,19 @@
     public PatchFile fileData;
     public List<Comment> comments = new ArrayList<>();
 
-    /** @return a web link to the given patch set and file. */
-    public String getFileLink() {
-      return args.urlFormatter
-          .get()
-          .getPatchFileView(change, patchSetId, KeyUtil.encode(filename))
-          .orElse(null);
+    /** @return a web link to a comment for a change. */
+    public String getCommentLink(String uuid) {
+      return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
     }
 
-    /** @return a web link to a comment within a given patch set and file. */
-    public String getCommentLink(short side, int startLine) {
-      return args.urlFormatter
-          .get()
-          .getInlineCommentView(change, patchSetId, KeyUtil.encode(filename), side, startLine)
-          .orElse(null);
+    /** @return a web link to the comment tab view of a change. */
+    public String getCommentsTabLink() {
+      return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
+    }
+
+    /** @return a web link to the findings tab view of a change. */
+    public String getFindingsTabLink() {
+      return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
     }
 
     /**
@@ -96,13 +95,15 @@
         return "Commit Message";
       } else if (Patch.MERGE_LIST.equals(filename)) {
         return "Merge List";
+      } else if (Patch.PATCHSET_LEVEL.equals(filename)) {
+        return "Patchset";
       } else {
         return "File " + filename;
       }
     }
   }
 
-  private List<Comment> inlineComments = Collections.emptyList();
+  private List<? extends Comment> inlineComments = Collections.emptyList();
   private String patchSetComment;
   private List<LabelVote> labels = Collections.emptyList();
   private final CommentsUtil commentsUtil;
@@ -124,7 +125,7 @@
     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
   }
 
-  public void setComments(List<Comment> comments) {
+  public void setComments(List<? extends Comment> comments) {
     inlineComments = comments;
   }
 
@@ -232,23 +233,21 @@
   /** Get the set of accounts whose comments have been replied to in this email. */
   private HashSet<Account.Id> getReplyAccounts() {
     HashSet<Account.Id> replyAccounts = new HashSet<>();
-
     // Track visited parent UUIDs to avoid cycles.
     HashSet<String> visitedUuids = new HashSet<>();
 
     for (Comment comment : inlineComments) {
       visitedUuids.add(comment.key.uuid);
-
       // Traverse the parent relation to the top of the comment thread.
       Comment current = comment;
       while (current.parentUuid != null && !visitedUuids.contains(current.parentUuid)) {
-        Optional<Comment> optParent = getParent(current);
+        Optional<HumanComment> optParent = getParent(current);
         if (!optParent.isPresent()) {
           // There is a parent UUID, but it cannot be loaded, break from the comment thread.
           break;
         }
 
-        Comment parent = optParent.get();
+        HumanComment parent = optParent.get();
         replyAccounts.add(parent.author.getId());
         visitedUuids.add(current.parentUuid);
         current = parent;
@@ -295,14 +294,13 @@
    * @return an optional comment that will be present if the given comment has a parent, and is
    *     empty if it does not.
    */
-  private Optional<Comment> getParent(Comment child) {
+  private Optional<HumanComment> getParent(Comment child) {
     if (child.parentUuid == null) {
       return Optional.empty();
     }
-
     Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
     try {
-      return commentsUtil.getPublished(changeData.notes(), key);
+      return commentsUtil.getPublishedHumanComment(changeData.notes(), key);
     } catch (StorageException e) {
       logger.atWarning().log("Could not find the parent of this comment: %s", child);
       return Optional.empty();
@@ -379,7 +377,6 @@
 
     for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
-      groupData.put("link", group.getFileLink());
       groupData.put("title", group.getTitle());
       groupData.put("patchSetId", group.patchSetId);
 
@@ -407,10 +404,15 @@
         commentData.put("startLine", startLine);
 
         // Set the comment link.
-        if (comment.lineNbr == 0) {
-          commentData.put("link", group.getFileLink());
+
+        if (comment.key.filename.equals(Patch.PATCHSET_LEVEL)) {
+          if (comment instanceof RobotComment) {
+            commentData.put("link", group.getFindingsTabLink());
+          } else {
+            commentData.put("link", group.getCommentsTabLink());
+          }
         } else {
-          commentData.put("link", group.getCommentLink(comment.side, startLine));
+          commentData.put("link", group.getCommentLink(comment.key.uuid));
         }
 
         // Set robot comment data.
@@ -427,7 +429,7 @@
         // If the comment has a quote, don't bother loading the parent message.
         if (!hasQuote(blocks)) {
           // Set parent comment info.
-          Optional<Comment> parent = getParent(comment);
+          Optional<HumanComment> parent = getParent(comment);
           if (parent.isPresent()) {
             commentData.put("parentMessage", getShortenedCommentMessage(parent.get()));
           }
@@ -553,9 +555,4 @@
     return MailProcessingUtil.rfcDateformatter.format(
         ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index 1f58abb..2ab73a8 100644
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -17,11 +17,11 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.RefPermission;
@@ -60,7 +60,7 @@
       // TODO(hiesel): Remove special handling for owners
       StreamSupport.stream(matching.all().accounts.spliterator(), false)
           .filter(this::isOwnerOfProjectOrBranch)
-          .forEach(acc -> add(RecipientType.TO, acc));
+          .forEach(acc -> addWatcher(RecipientType.TO, acc));
       // Add everyone else. Owners added above will not be duplicated.
       add(RecipientType.TO, matching.to);
       add(RecipientType.CC, matching.cc);
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index bbbfa1d..d6d306c 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -37,11 +37,16 @@
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
   private final List<String> gpgKeyFingerprints;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public DeleteKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted AccountSshKey sshKey) {
     super(args, "deletekey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.gpgKeyFingerprints = Collections.emptyList();
     this.sshKey = sshKey;
@@ -50,9 +55,11 @@
   @AssistedInject
   public DeleteKeySender(
       EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
       @Assisted IdentifiedUser user,
       @Assisted List<String> gpgKeyFingerprints) {
     super(args, "deletekey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.gpgKeyFingerprints = gpgKeyFingerprints;
     this.sshKey = null;
@@ -62,6 +69,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
+    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
     add(RecipientType.TO, user.getAccountId());
   }
 
@@ -88,11 +96,6 @@
     soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
   }
 
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
-
   private String getEmail() {
     return user.getAccount().preferredEmail();
   }
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index 4f42679..d5863a6 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 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.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
@@ -93,9 +93,4 @@
     super.setupSoyContext();
     soyContextEmailData.put("reviewerNames", getReviewerNames());
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
index 76f9b81..77efbf8 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -51,9 +51,4 @@
       appendHtml(soyHtmlTemplate("DeleteVoteHtml"));
     }
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/EmailSender.java b/java/com/google/gerrit/server/mail/send/EmailSender.java
index 9b3a1f7..711ab1b 100644
--- a/java/com/google/gerrit/server/mail/send/EmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/EmailSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import java.util.Collection;
 import java.util.Map;
 
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
index 61fa50d..a6d4f6d 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 
 /** Constructs an address to send email from. */
 public interface FromAddressGenerator {
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
index dfaabbe..ecf808d 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -19,7 +19,7 @@
 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;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
index abb8eda..045c6a4 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -28,11 +29,16 @@
 
   private final IdentifiedUser user;
   private final String operation;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public HttpPasswordUpdateSender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted String operation) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted String operation) {
     super(args, "HttpPasswordUpdate");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.operation = operation;
   }
@@ -41,6 +47,9 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
+    setMessageId(
+        messageIdGenerator.fromReasonAccountIdAndTimestamp(
+            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
     add(RecipientType.TO, user.getAccountId());
   }
 
@@ -66,11 +75,6 @@
     soyContextEmailData.put("operation", operation);
   }
 
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
-
   private String getEmail() {
     return user.getAccount().preferredEmail();
   }
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index 110f26a..709bf61 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -16,9 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.mail.MailHeader;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -90,9 +90,4 @@
     super.setupSoyContext();
     footers.add(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 92220eb..1b58057 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -41,8 +41,12 @@
     "AbandonedHtml.soy",
     "AddKey.soy",
     "AddKeyHtml.soy",
+    "AddToAttentionSet.soy",
+    "AddToAttentionSetHtml.soy",
     "ChangeFooter.soy",
     "ChangeFooterHtml.soy",
+    "ChangeHeader.soy",
+    "ChangeHeaderHtml.soy",
     "ChangeSubject.soy",
     "Comment.soy",
     "CommentHtml.soy",
@@ -58,7 +62,6 @@
     "InboundEmailRejectionHtml.soy",
     "Footer.soy",
     "FooterHtml.soy",
-    "HeaderHtml.soy",
     "HttpPasswordUpdate.soy",
     "HttpPasswordUpdateHtml.soy",
     "Merged.soy",
@@ -69,6 +72,9 @@
     "NoReplyFooterHtml.soy",
     "Private.soy",
     "RegisterNewEmail.soy",
+    "RegisterNewEmailHtml.soy",
+    "RemoveFromAttentionSet.soy",
+    "RemoveFromAttentionSetHtml.soy",
     "ReplacePatchSet.soy",
     "ReplacePatchSetHtml.soy",
     "Restored.soy",
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index b28a4dc..928bdc3 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -16,16 +16,16 @@
 
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.Table;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -131,9 +131,4 @@
     super.setupSoyContext();
     soyContextEmailData.put("approvals", getApprovals());
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
new file mode 100644
index 0000000..aa683f6
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.mail.send;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+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.mail.MailMessage;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.RepoView;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/** A generator class that creates a {@link MessageId} */
+public class MessageIdGenerator {
+  private final GitRepositoryManager repositoryManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public MessageIdGenerator(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
+    this.repositoryManager = repositoryManager;
+    this.allUsersName = allUsersName;
+  }
+
+  /**
+   * A unique id used which is a part of the header of all emails sent through by Gerrit. All of the
+   * emails are sent via {@link OutgoingEmail#send()}.
+   */
+  @AutoValue
+  public abstract static class MessageId {
+    public abstract String id();
+  }
+
+  /**
+   * Create a {@link MessageId} as a result of a change update.
+   *
+   * @param repoView
+   * @param patchsetId
+   * @return MessageId that depends on the patchset.
+   */
+  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
+    return fromChangeUpdateAndReason(repoView, patchsetId, null);
+  }
+
+  public MessageId fromChangeUpdateAndReason(
+      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason) {
+    String suffix = (reason != null) ? ("-" + reason) : "";
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Optional<ObjectId> metaSha1;
+    try {
+      metaSha1 = repoView.getRef(metaRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+    return metaSha1
+        .map(optional -> new AutoValue_MessageIdGenerator_MessageId(optional.getName() + suffix))
+        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
+  }
+
+  public MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId) {
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Ref ref = getRef(metaRef, project);
+    checkState(ref != null, metaRef + " must exist");
+    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
+  }
+
+  /**
+   * @param accountId Create a {@link MessageId} as a result of an account update.
+   * @return MessageId that depends on the account id.
+   */
+  public MessageId fromAccountUpdate(Account.Id accountId) {
+    String userRef = RefNames.refsUsers(accountId);
+    Ref ref = getRef(userRef, allUsersName);
+    checkState(ref != null, userRef + " must exist");
+    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
+  }
+
+  /**
+   * Create a {@link MessageId} from a mail message.
+   *
+   * @param mailMessage The message that was sent but was rejected.
+   * @return MessageId that depends on the MailMessage that was rejected.
+   */
+  public MessageId fromMailMessage(MailMessage mailMessage) {
+    return new AutoValue_MessageIdGenerator_MessageId(mailMessage.id() + "-REJECTION");
+  }
+
+  /**
+   * Create a {@link MessageId} from a reason, Account.Id, and timestamp.
+   *
+   * @param reason for performing this account update
+   * @param accountId
+   * @param timestamp
+   * @return MessageId that depends on the reason, accountId, and timestamp.
+   */
+  public MessageId fromReasonAccountIdAndTimestamp(
+      String reason, Account.Id accountId, Instant timestamp) {
+    return new AutoValue_MessageIdGenerator_MessageId(
+        reason + "-" + accountId.toString() + "-" + timestamp.toString());
+  }
+
+  private Ref getRef(String userRef, Project.NameKey project) {
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      return repository.getRefDatabase().findRef(userRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 83c3a94..0e97f7e 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 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.query.change.ChangeData;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -57,7 +57,6 @@
     super.init();
 
     String threadId = getChangeMessageThreadId();
-    setHeader("Message-ID", threadId);
     setHeader("References", threadId);
 
     switch (notify.handling()) {
@@ -103,9 +102,4 @@
     soyContext.put("ownerName", getNameFor(change.getOwner()));
     soyContextEmailData.put("reviewerNames", getReviewerNames());
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 0fb5c6f..91310ce 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -18,13 +18,13 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import java.util.HashMap;
 import java.util.Map;
@@ -82,13 +82,15 @@
   /** Add users or email addresses to the TO, CC, or BCC list. */
   protected void add(RecipientType type, Watchers.List list) {
     for (Account.Id user : list.accounts) {
-      add(type, user);
+      addWatcher(type, user);
     }
     for (Address addr : list.emails) {
       add(type, addr);
     }
   }
 
+  protected abstract void addWatcher(RecipientType type, Account.Id to);
+
   public String getSshHost() {
     String host = Iterables.getFirst(args.sshAddresses, null);
     if (host == null) {
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 8c56469..a23c978 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -22,14 +22,14 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.EmailHeader.AddressList;
 import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
-import com.google.gerrit.mail.EmailHeader.AddressList;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -67,6 +67,7 @@
   private Address smtpFromAddress;
   private StringBuilder textBody;
   private StringBuilder htmlBody;
+  private MessageIdGenerator.MessageId messageId;
   protected Map<String, Object> soyContext;
   protected Map<String, Object> soyContextEmailData;
   protected List<String> footers;
@@ -88,6 +89,10 @@
     this.notify = requireNonNull(notify);
   }
 
+  public void setMessageId(MessageIdGenerator.MessageId messageId) {
+    this.messageId = messageId;
+  }
+
   /**
    * Format and enqueue the message for delivery.
    *
@@ -108,8 +113,8 @@
     }
 
     init();
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("HeaderHtml"));
+    if (messageId == null) {
+      throw new IllegalStateException("All emails must have a messageId");
     }
     format();
     appendText(textTemplate("Footer"));
@@ -137,7 +142,8 @@
             // 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",
+                "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);
@@ -201,31 +207,21 @@
         va.htmlBody = null;
       }
 
-      for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
-        try {
-          validator.validateOutgoingEmail(va);
-        } catch (ValidationException e) {
-          logger.atFine().log(
-              "Not sending '%s': Rejected by outgoing email validator: %s",
-              messageClass, e.getMessage());
-          return;
-        }
-      }
-
       Set<Address> intersection = Sets.intersection(va.smtpRcptTo, smtpRcptToPlaintextOnly);
       if (!intersection.isEmpty()) {
         logger.atSevere().log("Email '%s' will be sent twice to %s", messageClass, intersection);
       }
-
       if (!va.smtpRcptTo.isEmpty()) {
         // Send multipart message
+        addMessageId(va, "-HTML");
+        if (!validateEmail(va)) return;
         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()) {
+        addMessageId(va, "-PLAIN");
         // Send plaintext message
         Map<String, EmailHeader> shallowCopy = new HashMap<>();
         shallowCopy.putAll(headers);
@@ -238,6 +234,7 @@
           to.add(a);
           shallowCopy.put(FieldName.TO, to);
         }
+        if (!validateEmail(va)) return;
         logger.atFine().log(
             "Sending plaintext '%s' from %s to %s",
             messageClass, va.smtpFromAddress, smtpRcptToPlaintextOnly);
@@ -246,6 +243,29 @@
     }
   }
 
+  private boolean validateEmail(OutgoingEmailValidationListener.Args va) {
+    for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
+      try {
+        validator.validateOutgoingEmail(va);
+      } catch (ValidationException e) {
+        logger.atFine().log(
+            "Not sending '%s': Rejected by outgoing email validator: %s",
+            messageClass, e.getMessage());
+        return false;
+      }
+    }
+    return true;
+  }
+
+  // All message ids must start with < and end with >. Also, they must have @domain and no spaces.
+  private void addMessageId(OutgoingEmailValidationListener.Args va, String suffix) {
+    if (messageId != null) {
+      String message = "<" + messageId.id() + suffix + "@" + getGerritHost() + ">";
+      message = message.replaceAll("\\s", "");
+      va.headers.put(FieldName.MESSAGE_ID, new EmailHeader.String(message));
+    }
+  }
+
   /** Format the message body by calling {@link #appendText(String)}. */
   protected abstract void format() throws EmailException;
 
@@ -262,7 +282,6 @@
     headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
     headers.put(FieldName.TO, new EmailHeader.AddressList());
     headers.put(FieldName.CC, new EmailHeader.AddressList());
-    setHeader(FieldName.MESSAGE_ID, "");
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : notify.accounts().keySet()) {
@@ -614,11 +633,6 @@
   }
 
   protected final boolean useHtml() {
-    return args.settings.html && supportsHtml();
-  }
-
-  /** Override this method to enable HTML in a subclass. */
-  protected boolean supportsHtml() {
-    return false;
+    return args.settings.html;
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index ef58744..0514337 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -17,20 +17,19 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 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.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
-import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -61,13 +60,15 @@
   }
 
   /** Returns all watchers that are relevant */
-  public final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+  public final Watchers getWatchers(
+      NotifyConfig.NotifyType type, boolean includeWatchersFromNotifyConfig) {
     Watchers matching = new Watchers();
     Set<Account.Id> projectWatchers = new HashSet<>();
 
     for (AccountState a : args.accountQueryProvider.get().byWatchedProject(project)) {
       Account.Id accountId = a.account().id();
-      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e : a.projectWatches().entrySet()) {
+      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> e :
+          a.projectWatches().entrySet()) {
         if (project.equals(e.getKey().project())
             && add(matching, accountId, e.getKey(), e.getValue(), type)) {
           // We only want to prevent matching All-Projects if this filter hits
@@ -77,7 +78,8 @@
     }
 
     for (AccountState a : args.accountQueryProvider.get().byWatchedProject(args.allProjectsName)) {
-      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e : a.projectWatches().entrySet()) {
+      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> e :
+          a.projectWatches().entrySet()) {
         if (args.allProjectsName.equals(e.getKey().project())) {
           Account.Id accountId = a.account().id();
           if (!projectWatchers.contains(accountId)) {
@@ -92,7 +94,7 @@
     }
 
     for (ProjectState state : projectState.tree()) {
-      for (NotifyConfig nc : state.getConfig().getNotifyConfigs()) {
+      for (NotifyConfig nc : state.getConfig().getNotifySections().values()) {
         if (nc.isNotify(type)) {
           try {
             add(matching, state.getNameKey(), nc);
@@ -212,8 +214,8 @@
       Watchers matching,
       Account.Id accountId,
       ProjectWatchKey key,
-      Set<NotifyType> watchedTypes,
-      NotifyType type) {
+      Set<NotifyConfig.NotifyType> watchedTypes,
+      NotifyConfig.NotifyType type) {
     logger.atFine().log("Checking project watch %s of account %s", key, accountId);
 
     IdentifiedUser user = args.identifiedUserFactory.create(accountId);
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index bb2efe6..a54a652 100644
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -16,9 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.entities.Address;
 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.mail.EmailTokenVerifier;
 import com.google.inject.Inject;
@@ -60,6 +60,9 @@
   @Override
   protected void format() throws EmailException {
     appendText(textTemplate("RegisterNewEmail"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("RegisterNewEmailHtml"));
+    }
   }
 
   public boolean isAllowed() {
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
new file mode 100644
index 0000000..6762b7d
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.mail.send;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Let users know of a user removed from the attention set. */
+public class RemoveFromAttentionSetSender extends AttentionSetSender {
+
+  public interface Factory extends ReplyToChangeSender.Factory<RemoveFromAttentionSetSender> {
+    @Override
+    RemoveFromAttentionSetSender create(Project.NameKey project, Change.Id changeId);
+  }
+
+  @Inject
+  public RemoveFromAttentionSetSender(
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, project, changeId);
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("RemoveFromAttentionSet"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("RemoveFromAttentionSetHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 909c52a..5caac37 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -16,11 +16,11 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
@@ -61,12 +61,14 @@
       //
       reviewers.remove(fromId);
     }
-    if (notify.handling() == NotifyHandling.ALL
-        || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
-      add(RecipientType.TO, reviewers);
-      add(RecipientType.CC, extraCC);
+    if (args.settings.sendNewPatchsetEmails) {
+      if (notify.handling() == NotifyHandling.ALL
+          || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
+        add(RecipientType.TO, reviewers);
+        add(RecipientType.CC, extraCC);
+      }
+      rcptToAuthors(RecipientType.CC);
     }
-    rcptToAuthors(RecipientType.CC);
     bccStarredBy();
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
     removeUsersThatIgnoredTheChange();
@@ -99,9 +101,4 @@
     super.setupSoyContext();
     soyContextEmailData.put("reviewerNames", getReviewerNames());
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
index 2a4c556..ffe70cf 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -51,9 +51,4 @@
       appendHtml(soyHtmlTemplate("RestoredHtml"));
     }
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
index dadd0d2..c11529b 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -50,9 +50,4 @@
       appendHtml(soyHtmlTemplate("RevertedHtml"));
     }
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
index 2b1e362..29f4c69 100644
--- a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
+++ b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
@@ -66,9 +66,4 @@
     super.setupSoyContext();
     soyContextEmailData.put("assigneeName", getNameFor(assignee));
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index cd342f7..1ad94be 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -21,9 +21,9 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.Version;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.Encryption;
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 9b5b4d4..e81160a 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -118,9 +118,10 @@
   private ObjectId revision;
   private boolean loaded;
 
-  protected AbstractChangeNotes(Args args, Change.Id changeId) {
+  protected AbstractChangeNotes(Args args, Change.Id changeId, @Nullable ObjectId metaSha1) {
     this.args = requireNonNull(args);
     this.changeId = requireNonNull(changeId);
+    this.revision = metaSha1;
   }
 
   public Change.Id getChangeId() {
@@ -133,6 +134,15 @@
   }
 
   public T load() {
+    try (Repository repo = args.repoManager.openRepository(getProjectName())) {
+      load(repo);
+      return self();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  public T load(Repository repo) {
     if (loaded) {
       return self();
     }
@@ -141,10 +151,9 @@
       throw new StorageException("Reading from NoteDb is disabled");
     }
     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.
-        LoadHandle handle = openHandle(repo)) {
+        LoadHandle handle = openHandle(repo, revision)) {
       revision = handle.id();
       onLoad(handle);
       loaded = true;
@@ -166,15 +175,16 @@
    * <p>Implementations may override this method to provide auto-rebuilding behavior.
    *
    * @param repo open repository.
+   * @param id version SHA1 of the change notes to load
    * @return handle for reading the entity.
    * @throws NoSuchChangeException change does not exist.
    * @throws IOException a repo-level error occurred.
    */
-  protected LoadHandle openHandle(Repository repo) throws NoSuchChangeException, IOException {
-    return openHandle(repo, readRef(repo));
-  }
-
-  protected LoadHandle openHandle(Repository repo, ObjectId id) {
+  protected LoadHandle openHandle(Repository repo, @Nullable ObjectId id)
+      throws NoSuchChangeException, IOException {
+    if (id == null) {
+      id = readRef(repo);
+    }
     return new LoadHandle(repo, id);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 7136d2b..8e6606e 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -110,7 +110,7 @@
       ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
-      return noteUtil.newIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
+      return noteUtil.newAccountIdIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
     } else if (u instanceof InternalUser) {
       return serverIdent;
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 4b538f3..57f6353 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -19,10 +19,10 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -37,7 +37,6 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -84,13 +83,13 @@
     FIXED
   }
 
-  private static Key key(Comment c) {
+  private static Key key(HumanComment c) {
     return new AutoValue_ChangeDraftUpdate_Key(c.getCommitId(), c.key);
   }
 
   private final AllUsersName draftsProject;
 
-  private List<Comment> put = new ArrayList<>();
+  private List<HumanComment> put = new ArrayList<>();
   private Map<Key, DeleteReason> delete = new HashMap<>();
 
   @AssistedInject
@@ -121,7 +120,7 @@
     this.draftsProject = allUsers;
   }
 
-  public void putComment(Comment c) {
+  public void putComment(HumanComment c) {
     checkState(!put.contains(c), "comment already added");
     verifyComment(c);
     put.add(c);
@@ -130,7 +129,7 @@
   /**
    * Marks a comment for deletion. Called when the comment is deleted because the user published it.
    */
-  public void markCommentPublished(Comment c) {
+  public void markCommentPublished(HumanComment c) {
     checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
     delete.put(key(c), DeleteReason.PUBLISHED);
@@ -139,7 +138,7 @@
   /**
    * Marks a comment for deletion. Called when the comment is deleted because the user removed it.
    */
-  public void deleteComment(Comment c) {
+  public void deleteComment(HumanComment c) {
     checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
     delete.put(key(c), DeleteReason.DELETED);
@@ -191,10 +190,9 @@
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<ObjectId> updatedCommits = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
-    for (Comment c : put) {
+    for (HumanComment c : put) {
       if (!delete.keySet().contains(key(c))) {
         cache.get(c.getCommitId()).putComment(c);
       }
@@ -207,7 +205,6 @@
     Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     boolean touchedAnyRevs = false;
     for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
-      updatedCommits.add(e.getKey());
       ObjectId id = e.getKey();
       byte[] data = e.getValue().build(noteUtil.getChangeNoteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
@@ -263,7 +260,7 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, Comment.Status.DRAFT);
+        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, HumanComment.Status.DRAFT);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 86b6ed7..15f187a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -69,16 +69,38 @@
     return changeNoteJson;
   }
 
-  public PersonIdent newIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
-    return new PersonIdent(
-        getUsername(accountId), getEmailAddress(accountId), when, serverIdent.getTimeZone());
+  /**
+   * Generates a user identifier that contains the account ID, but not the user's name or email
+   * address.
+   *
+   * @return The passed in {@link StringBuilder} instance to which the identifier has been appended.
+   */
+  StringBuilder appendAccountIdIdentString(StringBuilder stringBuilder, Account.Id accountId) {
+    return stringBuilder
+        .append(getAccountIdAsUsername(accountId))
+        .append(" <")
+        .append(getAccountIdAsEmailAddress(accountId))
+        .append('>');
   }
 
-  private static String getUsername(Account.Id accountId) {
+  /**
+   * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
+   * address.
+   */
+  public PersonIdent newAccountIdIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
+    return new PersonIdent(
+        getAccountIdAsUsername(accountId),
+        getAccountIdAsEmailAddress(accountId),
+        when,
+        serverIdent.getTimeZone());
+  }
+
+  /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
+  public static String getAccountIdAsUsername(Account.Id accountId) {
     return "Gerrit User " + accountId.toString();
   }
 
-  private String getEmailAddress(Account.Id accountId) {
+  private String getAccountIdAsEmailAddress(Account.Id accountId) {
     return accountId.get() + "@" + serverId;
   }
 
@@ -198,21 +220,10 @@
   }
 
   String attentionSetUpdateToJson(AttentionSetUpdate attentionSetUpdate) {
-    PersonIdent personIdent =
-        new PersonIdent(
-            getUsername(attentionSetUpdate.account()),
-            getEmailAddress(attentionSetUpdate.account()));
     StringBuilder stringBuilder = new StringBuilder();
-    appendIdentString(stringBuilder, personIdent.getName(), personIdent.getEmailAddress());
+    appendAccountIdIdentString(stringBuilder, attentionSetUpdate.account());
     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 51acf16e..28f25ec5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -38,19 +38,19 @@
 import com.google.common.collect.Sets.SetView;
 import com.google.common.flogger.FluentLogger;
 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;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
-import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -66,12 +66,14 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -133,22 +135,23 @@
       return createChecked(c.getProject(), c.getId());
     }
 
-    public ChangeNotes createChecked(Project.NameKey project, Change.Id changeId) {
+    public ChangeNotes createChecked(
+        Repository repo,
+        Project.NameKey project,
+        Change.Id changeId,
+        @Nullable ObjectId metaRevId) {
       Change change = newChange(project, changeId);
-      return new ChangeNotes(args, change, true, null).load();
+      return new ChangeNotes(args, change, true, null, metaRevId).load(repo);
     }
 
-    public ChangeNotes createChecked(Change.Id changeId) {
-      InternalChangeQuery query = queryProvider.get().noFields();
-      List<ChangeData> changes = query.byLegacyChangeId(changeId);
-      if (changes.isEmpty()) {
-        throw new NoSuchChangeException(changeId);
-      }
-      if (changes.size() != 1) {
-        logger.atSevere().log("Multiple changes found for %d", changeId.get());
-        throw new NoSuchChangeException(changeId);
-      }
-      return changes.get(0).notes();
+    public ChangeNotes createChecked(
+        Project.NameKey project, Change.Id changeId, @Nullable ObjectId metaRevId) {
+      Change change = newChange(project, changeId);
+      return new ChangeNotes(args, change, true, null, metaRevId).load();
+    }
+
+    public ChangeNotes createChecked(Project.NameKey project, Change.Id changeId) {
+      return createChecked(project, changeId, null);
     }
 
     public static Change newChange(Project.NameKey project, Change.Id changeId) {
@@ -161,6 +164,11 @@
       return new ChangeNotes(args, newChange(project, changeId), true, null).load();
     }
 
+    public ChangeNotes create(Repository repository, Project.NameKey project, Change.Id changeId) {
+      checkArgument(project != null, "project is required");
+      return new ChangeNotes(args, newChange(project, changeId), true, null).load(repository);
+    }
+
     /**
      * Create change notes for a change that was loaded from index. This method should only be used
      * when database access is harmful and potentially stale data from the index is acceptable.
@@ -180,11 +188,33 @@
       return new ChangeNotes(args, change, true, refs).load();
     }
 
-    public List<ChangeNotes> create(Collection<Change.Id> changeIds) {
+    /**
+     * Create change notes based on a {@link Change.Id}. This requires using the Change index and
+     * should only be used when {@link Project.NameKey} and the numeric change ID are not available.
+     */
+    public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
+      InternalChangeQuery query = queryProvider.get().noFields();
+      List<ChangeData> changes = query.byLegacyChangeId(changeId);
+      if (changes.isEmpty()) {
+        throw new NoSuchChangeException(changeId);
+      }
+      if (changes.size() != 1) {
+        logger.atSevere().log("Multiple changes found for %d", changeId.get());
+        throw new NoSuchChangeException(changeId);
+      }
+      return changes.get(0).notes();
+    }
+
+    /**
+     * Create change notes based on a list of {@link Change.Id}s. This requires using the Change
+     * index and should only be used when {@link Project.NameKey} and the numeric change ID are not
+     * available.
+     */
+    public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
       for (Change.Id changeId : changeIds) {
         try {
-          notes.add(createChecked(changeId));
+          notes.add(createCheckedUsingIndexLookup(changeId));
         } catch (NoSuchChangeException e) {
           // Ignore missing changes to match Access#get(Iterable) behavior.
         }
@@ -193,13 +223,14 @@
     }
 
     public List<ChangeNotes> create(
+        Repository repo,
         Project.NameKey project,
         Collection<Change.Id> changeIds,
         Predicate<ChangeNotes> predicate) {
       List<ChangeNotes> notes = new ArrayList<>();
       for (Change.Id cid : changeIds) {
         try {
-          ChangeNotes cn = create(project, cid);
+          ChangeNotes cn = create(repo, project, cid);
           if (cn.getChange() != null && predicate.test(cn)) {
             notes.add(cn);
           }
@@ -212,6 +243,28 @@
       return notes;
     }
 
+    /* TODO: This is now unused in the Gerrit code-base, however it is kept in the code
+    /* because it is a public method in a stable branch.
+     * It can be removed in master branch where we have more flexibility to change the API
+     * interface.
+     */
+    public List<ChangeNotes> create(
+        Project.NameKey project,
+        Collection<Change.Id> changeIds,
+        Predicate<ChangeNotes> predicate) {
+      try (Repository repo = args.repoManager.openRepository(project)) {
+        return create(repo, project, changeIds, predicate);
+      } catch (RepositoryNotFoundException e) {
+        // The repository does not exist, hence it does not contain
+        // any change.
+      } catch (IOException e) {
+        logger.atWarning().withCause(e).log(
+            "Unable to open project=%s when trying to retrieve changeId=%s from NoteDb",
+            project, changeIds);
+      }
+      return Collections.emptyList();
+    }
+
     public ListMultimap<Project.NameKey, ChangeNotes> create(Predicate<ChangeNotes> predicate)
         throws IOException {
       ListMultimap<Project.NameKey, ChangeNotes> m =
@@ -273,7 +326,7 @@
       ChangeNotes n = new ChangeNotes(args, rawChangeFromNoteDb, true, null);
       try {
         n.load();
-      } catch (StorageException e) {
+      } catch (Exception e) {
         return ChangeNotesResult.error(n.getChangeId(), e);
       }
       return ChangeNotesResult.notes(n);
@@ -282,7 +335,7 @@
     /** Result of {@link #scan(Repository,Project.NameKey)}. */
     @AutoValue
     public abstract static class ChangeNotesResult {
-      static ChangeNotesResult error(Change.Id id, StorageException e) {
+      static ChangeNotesResult error(Change.Id id, Throwable e) {
         return new AutoValue_ChangeNotes_Factory_ChangeNotesResult(id, Optional.of(e), null);
       }
 
@@ -295,7 +348,7 @@
       public abstract Change.Id id();
 
       /** Error encountered while loading this change, if any. */
-      public abstract Optional<StorageException> error();
+      public abstract Optional<Throwable> error();
 
       /**
        * Notes loaded for this change.
@@ -333,14 +386,23 @@
   private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
   private ImmutableSet<Comment.Key> commentKeys;
 
-  @VisibleForTesting
-  public ChangeNotes(Args args, Change change, boolean shouldExist, @Nullable RefCache refs) {
-    super(args, change.getId());
+  public ChangeNotes(
+      Args args,
+      Change change,
+      boolean shouldExist,
+      @Nullable RefCache refs,
+      @Nullable ObjectId metaSha1) {
+    super(args, change.getId(), metaSha1);
     this.change = new Change(change);
     this.shouldExist = shouldExist;
     this.refs = refs;
   }
 
+  @VisibleForTesting
+  public ChangeNotes(Args args, Change change, boolean shouldExist, @Nullable RefCache refs) {
+    this(args, change, shouldExist, refs, null);
+  }
+
   public Change getChange() {
     return change;
   }
@@ -438,14 +500,14 @@
   }
 
   /** @return inline comments on each revision. */
-  public ImmutableListMultimap<ObjectId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, HumanComment> getHumanComments() {
     return state.publishedComments();
   }
 
   public ImmutableSet<Comment.Key> getCommentKeys() {
     if (commentKeys == null) {
       ImmutableSet.Builder<Comment.Key> b = ImmutableSet.builder();
-      for (Comment c : getComments().values()) {
+      for (Comment c : getHumanComments().values()) {
         b.add(new Comment.Key(c.key));
       }
       commentKeys = b.build();
@@ -457,11 +519,11 @@
     return state.updateCount();
   }
 
-  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(Account.Id author) {
+  public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null);
   }
 
-  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(
+  public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(
       Account.Id author, @Nullable Ref ref) {
     loadDraftComments(author, ref);
     // Filter out any zombie draft comments. These are drafts that are also in
@@ -505,7 +567,7 @@
     return robotCommentNotes;
   }
 
-  public boolean containsComment(Comment c) {
+  public boolean containsComment(HumanComment c) {
     if (containsCommentPublished(c)) {
       return true;
     }
@@ -514,7 +576,7 @@
   }
 
   public boolean containsCommentPublished(Comment c) {
-    for (Comment l : getComments().values()) {
+    for (Comment l : getHumanComments().values()) {
       if (c.key.equals(l.key)) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 71cb8c9..76573f6 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.InsertedObject;
 import java.io.IOException;
@@ -127,4 +130,14 @@
     }
     return footerLines.get(key.getName().toLowerCase());
   }
+
+  public boolean isAttentionSetCommitOnly(boolean hasChangeMessage) {
+    return !hasChangeMessage
+        && footerLines
+            .keySet()
+            .equals(
+                Sets.newHashSet(
+                    FOOTER_PATCH_SET.getName().toLowerCase(),
+                    FOOTER_ATTENTION.getName().toLowerCase()));
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index a884b70..6f9f7e8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -55,18 +55,19 @@
 import com.google.common.collect.Tables;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -121,7 +122,7 @@
 
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
-  private final ListMultimap<ObjectId, Comment> comments;
+  private final ListMultimap<ObjectId, HumanComment> humanComments;
   private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
@@ -153,7 +154,10 @@
   private ReviewerByEmailSet pendingReviewersByEmail;
   private Change.Id revertOf;
   private int updateCount;
-  private PatchSet.Id cherryPickOf;
+  // Null indicates that the field was not parsed (yet).
+  // We only set the value once, based on the latest update (the actual value or Optional.empty() if
+  // the latest record unsets the field).
+  private Optional<PatchSet.Id> cherryPickOf;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -178,7 +182,7 @@
     assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
-    comments = MultimapBuilder.hashKeys().arrayListValues().build();
+    humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
     patchSets = new HashMap<>();
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
@@ -249,12 +253,12 @@
         assigneeUpdates,
         submitRecords,
         buildAllMessages(),
-        comments,
+        humanComments,
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
         revertOf,
-        cherryPickOf,
+        cherryPickOf != null ? cherryPickOf.orElse(null) : null,
         updateCount);
   }
 
@@ -317,7 +321,6 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    updateCount++;
     Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
 
     createdOn = ts;
@@ -360,7 +363,7 @@
       originalSubject = currSubject;
     }
 
-    parseChangeMessage(psId, accountId, realAccountId, commit, ts);
+    boolean hasChangeMessage = parseChangeMessage(psId, accountId, realAccountId, commit, ts);
     if (topic == null) {
       topic = parseTopic(commit);
     }
@@ -433,6 +436,9 @@
 
     previousWorkInProgressFooter = null;
     parseWorkInProgress(commit);
+    if (countTowardsMaxUpdatesLimit(commit, hasChangeMessage)) {
+      updateCount++;
+    }
   }
 
   private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
@@ -697,7 +703,7 @@
     }
   }
 
-  private void parseChangeMessage(
+  private boolean parseChangeMessage(
       PatchSet.Id psId,
       Account.Id accountId,
       Account.Id realAccountId,
@@ -705,7 +711,7 @@
       Timestamp ts) {
     Optional<String> changeMsgString = getChangeMessageString(commit);
     if (!changeMsgString.isPresent()) {
-      return;
+      return false;
     }
 
     ChangeMessage changeMessage =
@@ -713,7 +719,7 @@
     changeMessage.setMessage(changeMsgString.get());
     changeMessage.setTag(tag);
     changeMessage.setRealAuthor(realAccountId);
-    allChangeMessages.add(changeMessage);
+    return allChangeMessages.add(changeMessage);
   }
 
   public static Optional<String> getChangeMessageString(ChangeNotesCommit commit) {
@@ -735,12 +741,12 @@
     ChangeNotesCommit tipCommit = walk.parseCommit(tip);
     revisionNoteMap =
         RevisionNoteMap.parse(
-            changeNoteJson, reader, NoteMap.read(reader, tipCommit), Comment.Status.PUBLISHED);
+            changeNoteJson, reader, NoteMap.read(reader, tipCommit), HumanComment.Status.PUBLISHED);
     Map<ObjectId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
     for (Map.Entry<ObjectId, ChangeRevisionNote> e : rns.entrySet()) {
-      for (Comment c : e.getValue().getEntities()) {
-        comments.put(e.getKey(), c);
+      for (HumanComment c : e.getValue().getEntities()) {
+        humanComments.put(e.getKey(), c);
       }
     }
 
@@ -997,15 +1003,31 @@
     return Change.id(revertOf);
   }
 
-  private PatchSet.Id parseCherryPickOf(ChangeNotesCommit commit) throws ConfigInvalidException {
-    String cherryPickOf = parseOneFooter(commit, FOOTER_CHERRY_PICK_OF);
-    if (cherryPickOf == null) {
+  /**
+   * Parses {@link ChangeNoteUtil#FOOTER_CHERRY_PICK_OF} of the commit.
+   *
+   * @param commit the commit to parse.
+   * @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
+   *     this commit.
+   * @throws ConfigInvalidException if the footer value could not be parsed as a valid {@link
+   *     PatchSet.Id}.
+   */
+  @Nullable
+  private Optional<PatchSet.Id> parseCherryPickOf(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    String footer = parseOneFooter(commit, FOOTER_CHERRY_PICK_OF);
+    if (footer == null) {
+      // The footer is missing, nothing to parse.
       return null;
-    }
-    try {
-      return PatchSet.Id.parse(cherryPickOf);
-    } catch (IllegalArgumentException e) {
-      throw new ConfigInvalidException("\"" + cherryPickOf + "\" is not a valid patchset", e);
+    } else if (footer.equals("")) {
+      // Empty footer value, cherryPickOf was unset at this commit.
+      return Optional.empty();
+    } else {
+      try {
+        return Optional.of(PatchSet.Id.parse(footer));
+      } catch (IllegalArgumentException e) {
+        throw new ConfigInvalidException("\"" + footer + "\" is not a valid patchset", e);
+      }
     }
   }
 
@@ -1055,7 +1077,7 @@
         pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
-            comments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
+            humanComments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
             approvals.values(), psa -> psa.key().patchSetId(), missing);
@@ -1137,4 +1159,9 @@
         .orElseThrow(
             () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
   }
+
+  protected boolean countTowardsMaxUpdatesLimit(
+      ChangeNotesCommit commit, boolean hasChangeMessage) {
+    return !commit.isAttentionSetCommitOnly(hasChangeMessage);
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 0f27b75..76c4678 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -33,22 +33,22 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 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.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -123,7 +123,7 @@
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
-      ListMultimap<ObjectId, Comment> publishedComments,
+      ListMultimap<ObjectId, HumanComment> publishedComments,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
@@ -314,7 +314,7 @@
 
   abstract ImmutableList<ChangeMessage> changeMessages();
 
-  abstract ImmutableListMultimap<ObjectId, Comment> publishedComments();
+  abstract ImmutableListMultimap<ObjectId, HumanComment> publishedComments();
 
   abstract int updateCount();
 
@@ -427,7 +427,7 @@
 
     abstract Builder changeMessages(List<ChangeMessage> changeMessages);
 
-    abstract Builder publishedComments(ListMultimap<ObjectId, Comment> publishedComments);
+    abstract Builder publishedComments(ListMultimap<ObjectId, HumanComment> publishedComments);
 
     abstract Builder updateCount(int updateCount);
 
@@ -634,8 +634,8 @@
                       .collect(toImmutableList()))
               .publishedComments(
                   proto.getPublishedCommentList().stream()
-                      .map(r -> GSON.fromJson(r, Comment.class))
-                      .collect(toImmutableListMultimap(Comment::getCommitId, c -> c)))
+                      .map(r -> GSON.fromJson(r, HumanComment.class))
+                      .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
               .updateCount(proto.getUpdateCount());
       return b.build();
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 4e52093..bf2cf07 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -16,7 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -29,13 +29,13 @@
 import org.eclipse.jgit.util.MutableInteger;
 
 /** Implements the parsing of comment data, handling JSON decoding and push certificates. */
-class ChangeRevisionNote extends RevisionNote<Comment> {
+class ChangeRevisionNote extends RevisionNote<HumanComment> {
   private final ChangeNoteJson noteJson;
-  private final Comment.Status status;
+  private final HumanComment.Status status;
   private String pushCert;
 
   ChangeRevisionNote(
-      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, Comment.Status status) {
+      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
     super(reader, noteId);
     this.noteJson = noteJson;
     this.status = status;
@@ -47,12 +47,13 @@
   }
 
   @Override
-  protected List<Comment> parse(byte[] raw, int offset) throws IOException, ConfigInvalidException {
+  protected List<HumanComment> parse(byte[] raw, int offset)
+      throws IOException, ConfigInvalidException {
     MutableInteger p = new MutableInteger();
     p.value = offset;
 
-    RevisionNoteData data = parseJson(noteJson, raw, p.value);
-    if (status == Comment.Status.PUBLISHED) {
+    HumanCommentsRevisionNoteData data = parseJson(noteJson, raw, p.value);
+    if (status == HumanComment.Status.PUBLISHED) {
       pushCert = data.pushCert;
     } else {
       pushCert = null;
@@ -60,11 +61,11 @@
     return data.comments;
   }
 
-  private RevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
+  private HumanCommentsRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
       throws IOException {
     try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
         Reader r = new InputStreamReader(is, UTF_8)) {
-      return noteUtil.getGson().fromJson(r, RevisionNoteData.class);
+      return noteUtil.getGson().fromJson(r, HumanCommentsRevisionNoteData.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 63f4e5d..52c551e 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -50,21 +50,26 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Table;
 import com.google.common.collect.TreeBasedTable;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
 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.account.ServiceUserClassifier;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.assistedinject.Assisted;
@@ -73,6 +78,7 @@
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -80,6 +86,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -114,11 +121,12 @@
   private final ChangeDraftUpdate.Factory draftUpdateFactory;
   private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
   private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
+  private final ServiceUserClassifier serviceUserClassifier;
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
-  private final List<Comment> comments = new ArrayList<>();
+  private final List<HumanComment> comments = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -129,7 +137,8 @@
   private String submissionId;
   private String topic;
   private String commit;
-  private Set<AttentionSetUpdate> attentionSetUpdates;
+  private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates;
+  private boolean ignoreFurtherAttentionSetUpdates;
   private Optional<Account.Id> assignee;
   private Set<String> hashtags;
   private String changeMessage;
@@ -143,7 +152,9 @@
   private Boolean isPrivate;
   private Boolean workInProgress;
   private Integer revertOf;
-  private String cherryPickOf;
+  // If null, the update does not modify the field. Otherwise, it updates the field with the
+  // new value or resets if cherryPickOf == Optional.empty().
+  private Optional<String> cherryPickOf;
 
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
@@ -158,6 +169,7 @@
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
+      ServiceUserClassifier serviceUserClassifier,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
       @Assisted Date when,
@@ -168,6 +180,7 @@
         draftUpdateFactory,
         robotCommentUpdateFactory,
         deleteCommentRewriterFactory,
+        serviceUserClassifier,
         notes,
         user,
         when,
@@ -191,6 +204,7 @@
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
+      ServiceUserClassifier serviceUserClassifier,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
       @Assisted Date when,
@@ -201,6 +215,7 @@
     this.draftUpdateFactory = draftUpdateFactory;
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
+    this.serviceUserClassifier = serviceUserClassifier;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -285,10 +300,10 @@
     this.psDescription = psDescription;
   }
 
-  public void putComment(Comment.Status status, Comment c) {
+  public void putComment(HumanComment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
-    if (status == Comment.Status.DRAFT) {
+    if (status == HumanComment.Status.DRAFT) {
       draftUpdate.putComment(c);
     } else {
       comments.add(c);
@@ -302,7 +317,7 @@
     robotCommentUpdate.putComment(c);
   }
 
-  public void deleteComment(Comment c) {
+  public void deleteComment(HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull().deleteComment(c);
   }
@@ -372,17 +387,40 @@
 
   /**
    * 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.
+   * not be multiple updates for a single user. Only the first update takes place because of the
+   * different priorities: e.g, if we want to add someone to the attention set but also want to
+   * remove someone from the attention set, we should ensure to add/remove that user based on the
+   * priority of the addition and removal. If most importantly we want to remove the user, then we
+   * must first create the removal, and the addition will not take effect.
    */
-  public void setAttentionSetUpdates(Set<AttentionSetUpdate> attentionSetUpdates) {
+  public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) {
+    if (updates == null || updates.isEmpty() || ignoreFurtherAttentionSetUpdates) {
+      // No updates to do. Robots don't change attention set.
+      return;
+    }
     checkArgument(
-        attentionSetUpdates.stream().noneMatch(a -> a.timestamp() != null),
+        updates.stream().noneMatch(a -> a.timestamp() != null),
         "must not specify timestamp for write");
+
     checkArgument(
-        attentionSetUpdates.stream().map(AttentionSetUpdate::account).distinct().count()
-            == attentionSetUpdates.size(),
+        updates.stream().map(AttentionSetUpdate::account).distinct().count() == updates.size(),
         "must not specify multiple updates for single user");
-    this.attentionSetUpdates = attentionSetUpdates;
+
+    if (plannedAttentionSetUpdates == null) {
+      plannedAttentionSetUpdates = new HashMap<>();
+    }
+
+    Set<Account.Id> currentAccountUpdates =
+        plannedAttentionSetUpdates.values().stream()
+            .map(AttentionSetUpdate::account)
+            .collect(Collectors.toSet());
+    updates.stream()
+        .filter(u -> !currentAccountUpdates.contains(u.account()))
+        .forEach(u -> plannedAttentionSetUpdates.putIfAbsent(u.account(), u));
+  }
+
+  public void addToPlannedAttentionSetUpdates(AttentionSetUpdate update) {
+    addToPlannedAttentionSetUpdates(ImmutableSet.of(update));
   }
 
   public void setAssignee(Account.Id assignee) {
@@ -437,7 +475,12 @@
   }
 
   public void setCherryPickOf(String cherryPickOf) {
-    this.cherryPickOf = cherryPickOf;
+    checkArgument(cherryPickOf != null, "use resetCherryPickOf");
+    this.cherryPickOf = Optional.of(cherryPickOf);
+  }
+
+  public void resetCherryPickOf() {
+    this.cherryPickOf = Optional.empty();
   }
 
   /** @return the tree id for the updated tree */
@@ -449,7 +492,7 @@
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
 
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
-    for (Comment c : comments) {
+    for (HumanComment c : comments) {
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
@@ -486,7 +529,7 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, Comment.Status.PUBLISHED);
+        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, HumanComment.Status.PUBLISHED);
   }
 
   private void checkComments(
@@ -534,10 +577,27 @@
 
   @Override
   protected boolean bypassMaxUpdates() {
-    // Allow abandoning or submitting a change even if it would exceed the max update count.
+    return isAbandonChange() || isAttentionSetChangeOnly();
+  }
+
+  private boolean isAbandonChange() {
     return status != null && status.isClosed();
   }
 
+  private boolean isAttentionSetChangeOnly() {
+    return (plannedAttentionSetUpdates != null
+        && plannedAttentionSetUpdates.size() > 0
+        && doesNotHaveChangesAffectingAttentionSet());
+  }
+
+  private boolean doesNotHaveChangesAffectingAttentionSet() {
+    return comments.isEmpty()
+        && reviewers.isEmpty()
+        && reviewersByEmail.isEmpty()
+        && approvals.isEmpty()
+        && workInProgress == null;
+  }
+
   @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
       throws IOException {
@@ -583,6 +643,12 @@
 
     if (status != null) {
       addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
+      if (status.equals(Change.Status.ABANDONED)) {
+        clearAttentionSet("Change was abandoned");
+      }
+      if (status.equals(Change.Status.MERGED)) {
+        clearAttentionSet("Change was submitted");
+      }
     }
 
     if (topic != null) {
@@ -593,16 +659,10 @@
       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);
-        addIdent(msg, assignee.get()).append('\n');
+        noteUtil.appendAccountIdIdentString(msg, assignee.get()).append('\n');
       } else {
         addFooter(msg, FOOTER_ASSIGNEE).append('\n');
       }
@@ -623,9 +683,11 @@
 
     for (Map.Entry<Account.Id, ReviewerStateInternal> e : reviewers.entrySet()) {
       addFooter(msg, e.getValue().getFooterKey());
-      addIdent(msg, e.getKey()).append('\n');
+      noteUtil.appendAccountIdIdentString(msg, e.getKey()).append('\n');
     }
 
+    applyReviewerUpdatesToAttentionSet();
+
     for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
       addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
     }
@@ -640,7 +702,7 @@
       }
       Account.Id id = c.getColumnKey();
       if (!id.equals(getAccountId())) {
-        addIdent(msg.append(' '), id);
+        noteUtil.appendAccountIdIdentString(msg.append(' '), id);
       }
       msg.append('\n');
     }
@@ -666,7 +728,7 @@
                 .append(label.label);
             if (label.appliedBy != null) {
               msg.append(": ");
-              addIdent(msg, label.appliedBy);
+              noteUtil.appendAccountIdIdentString(msg, label.appliedBy);
             }
             msg.append('\n');
           }
@@ -677,7 +739,7 @@
 
     if (!Objects.equals(accountId, realAccountId)) {
       addFooter(msg, FOOTER_REAL_USER);
-      addIdent(msg, realAccountId).append('\n');
+      noteUtil.appendAccountIdIdentString(msg, realAccountId).append('\n');
     }
 
     if (isPrivate != null) {
@@ -686,6 +748,11 @@
 
     if (workInProgress != null) {
       addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress);
+      if (workInProgress) {
+        clearAttentionSet("Change was marked work in progress");
+      } else {
+        addAllReviewersToAttentionSet();
+      }
     }
 
     if (revertOf != null) {
@@ -693,7 +760,16 @@
     }
 
     if (cherryPickOf != null) {
-      addFooter(msg, FOOTER_CHERRY_PICK_OF, cherryPickOf);
+      if (cherryPickOf.isPresent()) {
+        addFooter(msg, FOOTER_CHERRY_PICK_OF, cherryPickOf.get());
+      } else {
+        // Update cherryPickOf with an empty value.
+        addFooter(msg, FOOTER_CHERRY_PICK_OF).append('\n');
+      }
+    }
+
+    if (plannedAttentionSetUpdates != null) {
+      updateAttentionSet(msg);
     }
 
     CommitBuilder cb = new CommitBuilder();
@@ -709,6 +785,114 @@
     return cb;
   }
 
+  private void clearAttentionSet(String reason) {
+    if (getNotes().getAttentionSet() == null) {
+      return;
+    }
+    AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
+        .map(
+            a ->
+                AttentionSetUpdate.createForWrite(
+                    a.account(), AttentionSetUpdate.Operation.REMOVE, reason))
+        .forEach(this::addToPlannedAttentionSetUpdates);
+  }
+
+  private void applyReviewerUpdatesToAttentionSet() {
+    if ((workInProgress != null && workInProgress == true)
+        || getNotes().getChange().isWorkInProgress()
+        || status == Change.Status.MERGED) {
+      // Attention set shouldn't change here for changes that are work in progress or are about to
+      // be submitted or when the caller is a robot.
+      return;
+    }
+    Set<Account.Id> currentReviewers =
+        getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
+    Set<AttentionSetUpdate> updates = new HashSet<>();
+    for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
+      // Only add new reviewers to the attention set.
+      if (reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
+          && !currentReviewers.contains(reviewer.getKey())) {
+        updates.add(
+            AttentionSetUpdate.createForWrite(
+                reviewer.getKey(), AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
+      }
+      boolean reviewerRemoved =
+          !reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
+              && currentReviewers.contains(reviewer.getKey());
+      boolean ccRemoved = reviewer.getValue().equals(ReviewerStateInternal.REMOVED);
+      if (reviewerRemoved || ccRemoved) {
+        updates.add(
+            AttentionSetUpdate.createForWrite(
+                reviewer.getKey(), AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed"));
+      }
+    }
+    addToPlannedAttentionSetUpdates(updates);
+  }
+
+  private void addAllReviewersToAttentionSet() {
+    getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER).stream()
+        .map(
+            r ->
+                AttentionSetUpdate.createForWrite(
+                    r, AttentionSetUpdate.Operation.ADD, "Change was marked ready for review"))
+        .forEach(this::addToPlannedAttentionSetUpdates);
+  }
+
+  /**
+   * Any updates to the attention set must be done in {@link #addToPlannedAttentionSetUpdates}. This
+   * method is called after all the updates are finished to do the updates once and for real.
+   *
+   * <p>Changing the behaviour of this method might affect the way a ChangeUpdate is considered to
+   * be an "Attention Set Change Only". Make sure the {@link #isAttentionSetChangeOnly} logic is
+   * amended as well if needed.
+   */
+  private void updateAttentionSet(StringBuilder msg) {
+    if (plannedAttentionSetUpdates == null) {
+      return;
+    }
+    Set<Account.Id> currentUsersInAttentionSet =
+        AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
+            .map(AttentionSetUpdate::account)
+            .collect(Collectors.toSet());
+    for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+          && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
+        // Skip users that are already in the attention set: no need to re-add them.
+        continue;
+      }
+
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.REMOVE
+          && !currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
+        // Skip users that are not in the attention set: no need to remove them.
+        continue;
+      }
+
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+          && serviceUserClassifier.isServiceUser(attentionSetUpdate.account())) {
+        // Skip adding robots to the attention set.
+        continue;
+      }
+
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+          && approvals.rowKeySet().contains(LabelId.legacySubmit().get())) {
+        // On submit, we sometimes can add the person who submitted the change as a reviewer, and in
+        // turn it will add that person to the attention set.
+        // This ensures we don't add users to the attention set on submit.
+        continue;
+      }
+
+      addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+    }
+  }
+
+  /**
+   * When set, default attention set rules are ignored (E.g, adding reviewers -> adds to attention
+   * set, etc).
+   */
+  public void ignoreFurtherAttentionSetUpdates() {
+    ignoreFurtherAttentionSetUpdates = true;
+  }
+
   private void addPatchSetFooter(StringBuilder sb, int ps) {
     addFooter(sb, FOOTER_PATCH_SET).append(ps);
     if (psState != null) {
@@ -735,7 +919,7 @@
         && status == null
         && submissionId == null
         && submitRecords == null
-        && attentionSetUpdates == null
+        && plannedAttentionSetUpdates == null
         && assignee == null
         && hashtags == null
         && topic == null
@@ -799,10 +983,4 @@
   private static boolean isIllegalTopic(String topic) {
     return (topic != null && topic.contains("\""));
   }
-
-  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/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index 9c8b369..d0b6247 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.entities.Comment.Status;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.RefNames;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -94,14 +93,14 @@
 
     ObjectReader reader = revWalk.getObjectReader();
     RevCommit newTipCommit = revWalk.next(); // The first commit will not be rewritten.
-    Map<String, Comment> parentComments =
+    Map<String, HumanComment> parentComments =
         getPublishedComments(noteUtil, reader, NoteMap.read(reader, newTipCommit));
 
     boolean rewrite = false;
     RevCommit originalCommit;
     while ((originalCommit = revWalk.next()) != null) {
       NoteMap noteMap = NoteMap.read(reader, originalCommit);
-      Map<String, Comment> currComments = getPublishedComments(noteUtil, reader, noteMap);
+      Map<String, HumanComment> currComments = getPublishedComments(noteUtil, reader, noteMap);
 
       if (!rewrite && currComments.containsKey(uuid)) {
         rewrite = true;
@@ -113,8 +112,8 @@
         continue;
       }
 
-      List<Comment> putInComments = getPutInComments(parentComments, currComments);
-      List<Comment> deletedComments = getDeletedComments(parentComments, currComments);
+      List<HumanComment> putInComments = getPutInComments(parentComments, currComments);
+      List<HumanComment> deletedComments = getDeletedComments(parentComments, currComments);
       newTipCommit =
           revWalk.parseCommit(
               rewriteCommit(
@@ -130,16 +129,16 @@
    * the previous commits.
    */
   @VisibleForTesting
-  public static Map<String, Comment> getPublishedComments(
+  public static Map<String, HumanComment> getPublishedComments(
       ChangeNoteJson changeNoteJson, ObjectReader reader, NoteMap noteMap)
       throws IOException, ConfigInvalidException {
-    return RevisionNoteMap.parse(changeNoteJson, reader, noteMap, Status.PUBLISHED).revisionNotes
-        .values().stream()
+    return RevisionNoteMap.parse(changeNoteJson, reader, noteMap, HumanComment.Status.PUBLISHED)
+        .revisionNotes.values().stream()
         .flatMap(n -> n.getEntities().stream())
         .collect(toMap(c -> c.key.uuid, Function.identity()));
   }
 
-  public static Map<String, Comment> getPublishedComments(
+  public static Map<String, HumanComment> getPublishedComments(
       ChangeNoteUtil noteUtil, ObjectReader reader, NoteMap noteMap)
       throws IOException, ConfigInvalidException {
     return getPublishedComments(noteUtil.getChangeNoteJson(), reader, noteMap);
@@ -152,11 +151,12 @@
    * @param curMap the comment map of the current commit.
    * @return The comments put in by the current commit.
    */
-  private List<Comment> getPutInComments(Map<String, Comment> parMap, Map<String, Comment> curMap) {
-    List<Comment> comments = new ArrayList<>();
+  private List<HumanComment> getPutInComments(
+      Map<String, HumanComment> parMap, Map<String, HumanComment> curMap) {
+    List<HumanComment> comments = new ArrayList<>();
     for (String key : curMap.keySet()) {
       if (!parMap.containsKey(key)) {
-        Comment comment = curMap.get(key);
+        HumanComment comment = curMap.get(key);
         if (key.equals(uuid)) {
           comment.message = newMessage;
         }
@@ -173,8 +173,8 @@
    * @param curMap the comment map of the current commit.
    * @return The comments deleted by the current commit.
    */
-  private List<Comment> getDeletedComments(
-      Map<String, Comment> parMap, Map<String, Comment> curMap) {
+  private List<HumanComment> getDeletedComments(
+      Map<String, HumanComment> parMap, Map<String, HumanComment> curMap) {
     return parMap.entrySet().stream()
         .filter(c -> !curMap.containsKey(c.getKey()))
         .map(Map.Entry::getValue)
@@ -199,22 +199,22 @@
       RevCommit parentCommit,
       ObjectInserter inserter,
       ObjectReader reader,
-      List<Comment> putInComments,
-      List<Comment> deletedComments)
+      List<HumanComment> putInComments,
+      List<HumanComment> deletedComments)
       throws IOException, ConfigInvalidException {
     RevisionNoteMap<ChangeRevisionNote> revNotesMap =
         RevisionNoteMap.parse(
             noteUtil.getChangeNoteJson(),
             reader,
             NoteMap.read(reader, parentCommit),
-            Status.PUBLISHED);
+            HumanComment.Status.PUBLISHED);
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNotesMap);
 
-    for (Comment c : putInComments) {
+    for (HumanComment c : putInComments) {
       cache.get(c.getCommitId()).putComment(c);
     }
 
-    for (Comment c : deletedComments) {
+    for (HumanComment c : deletedComments) {
       cache.get(c.getCommitId()).deleteComment(c.key);
     }
 
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 3966396..4988406 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -26,7 +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.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Project;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -50,7 +50,7 @@
   private final Account.Id author;
   private final Ref ref;
 
-  private ImmutableListMultimap<ObjectId, Comment> comments;
+  private ImmutableListMultimap<ObjectId, HumanComment> comments;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   @AssistedInject
@@ -59,7 +59,7 @@
   }
 
   DraftCommentNotes(Args args, Change.Id changeId, Account.Id author, @Nullable Ref ref) {
-    super(args, changeId);
+    super(args, changeId, null);
     this.author = requireNonNull(author);
     this.ref = ref;
     if (ref != null) {
@@ -80,12 +80,12 @@
     return author;
   }
 
-  public ImmutableListMultimap<ObjectId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, HumanComment> getComments() {
     return comments;
   }
 
-  public boolean containsComment(Comment c) {
-    for (Comment existing : comments.values()) {
+  public boolean containsComment(HumanComment c) {
+    for (HumanComment existing : comments.values()) {
       if (c.key.equals(existing.key)) {
         return true;
       }
@@ -120,10 +120,13 @@
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap =
         RevisionNoteMap.parse(
-            args.changeNoteJson, reader, NoteMap.read(reader, tipCommit), Comment.Status.DRAFT);
-    ListMultimap<ObjectId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+            args.changeNoteJson,
+            reader,
+            NoteMap.read(reader, tipCommit),
+            HumanComment.Status.DRAFT);
+    ListMultimap<ObjectId, HumanComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
-      for (Comment c : rn.getEntities()) {
+      for (HumanComment c : rn.getEntities()) {
         cs.put(c.getCommitId(), c);
       }
     }
diff --git a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java b/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
new file mode 100644
index 0000000..e570412
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.entities.HumanComment;
+import java.util.List;
+
+/**
+ * Holds the raw data of a RevisionNote.
+ *
+ * <p>It is intended for deserialization from JSON only. It is used for human comments only.
+ */
+class HumanCommentsRevisionNoteData {
+  String pushCert;
+  List<HumanComment> comments;
+}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 2d1a04a..65758f9 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -16,8 +16,11 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.logging.TraceContext.newTimer;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
@@ -31,6 +34,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 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.update.BatchUpdateListener;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -91,6 +97,7 @@
   private String refLogMessage;
   private PersonIdent refLogIdent;
   private PushCertificate pushCert;
+  private ImmutableList<BatchUpdateListener> batchUpdateListeners;
 
   @Inject
   NoteDbUpdateManager(
@@ -114,6 +121,7 @@
     robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     rewriters = MultimapBuilder.hashKeys().arrayListValues().build();
     changesToDelete = new HashSet<>();
+    batchUpdateListeners = ImmutableList.of();
   }
 
   @Override
@@ -169,6 +177,17 @@
     return this;
   }
 
+  public NoteDbUpdateManager setBatchUpdateListeners(
+      ImmutableList<BatchUpdateListener> batchUpdateListeners) {
+    checkNotNull(batchUpdateListeners);
+    this.batchUpdateListeners = batchUpdateListeners;
+    return this;
+  }
+
+  public boolean isExecuted() {
+    return executed;
+  }
+
   private void initChangeRepo() throws IOException {
     if (changeRepo == null) {
       changeRepo = OpenRepo.open(repoManager, projectName);
@@ -307,8 +326,15 @@
       // we may have stale draft comments. Doing it in this order allows stale
       // comments to be filtered out by ChangeNotes, reflecting the fact that
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
-      BatchRefUpdate result = execute(changeRepo, dryrun, pushCert);
-      execute(allUsersRepo, dryrun, null);
+      BatchRefUpdate result;
+      try (TraceContext.TraceTimer ignored =
+          newTimer("NoteDbUpdateManager#updateRepo", Metadata.empty())) {
+        result = execute(changeRepo, dryrun, pushCert);
+      }
+      try (TraceContext.TraceTimer ignored =
+          newTimer("NoteDbUpdateManager#updateAllUsersSync", Metadata.empty())) {
+        execute(allUsersRepo, dryrun, null);
+      }
       if (!dryrun) {
         // Only execute the asynchronous operation if we are not in dry-run mode: The dry run would
         // have to run synchronous to be of any value at all. For the removal of draft comments from
@@ -348,6 +374,9 @@
     bru.setAtomic(true);
     or.cmds.addTo(bru);
     bru.setAllowNonFastForwards(true);
+    for (BatchUpdateListener listener : batchUpdateListeners) {
+      bru = listener.beforeUpdateRefs(bru);
+    }
 
     if (!dryrun) {
       RefUpdateUtil.executeChecked(bru, or.rw);
diff --git a/java/com/google/gerrit/server/notedb/OpenRepo.java b/java/com/google/gerrit/server/notedb/OpenRepo.java
index 351f31d..d02ec87 100644
--- a/java/com/google/gerrit/server/notedb/OpenRepo.java
+++ b/java/com/google/gerrit/server/notedb/OpenRepo.java
@@ -178,9 +178,9 @@
             && !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.",
+                  "Change %s may not exceed %d updates. It may still be abandoned, submitted and you can add/remove"
+                      + " reviewers to/from the attention-set. 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 47e12ff..d743921 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -340,4 +340,16 @@
       counterLock.unlock();
     }
   }
+
+  /**
+   * Retrieves the last returned sequence number.
+   *
+   * <p>Explicitly calls {@link #next()} if this instance didn't return sequence number until now.
+   */
+  public int last() {
+    if (counter == 0) {
+      next();
+    }
+    return counter - 1;
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
index c0e09ed..da15b34 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -20,7 +20,8 @@
 /**
  * Holds the raw data of a RevisionNote.
  *
- * <p>It is intended for (de)serialization to JSON only.
+ * <p>It is intended for serialization to JSON only. It is used for human comments and robot
+ * comments.
  */
 class RevisionNoteData {
   String pushCert;
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 98c9873..5a0b67b 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -41,7 +42,7 @@
   }
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
-      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, Comment.Status status)
+      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, HumanComment.Status status)
       throws ConfigInvalidException, IOException {
     ImmutableMap.Builder<ObjectId, ChangeRevisionNote> result = ImmutableMap.builder();
     for (Note note : noteMap) {
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index fe05643..d53b2ca 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -47,7 +47,7 @@
 
   @Inject
   RobotCommentNotes(Args args, @Assisted Change change) {
-    super(args, change.getId());
+    super(args, change.getId(), null);
     this.change = change;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
index fc4c9fd..010206c 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
@@ -26,7 +26,11 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 
-/** Like {@link RevisionNote} but for robot comments. */
+/**
+ * Holds the raw data of a RevisionNote.
+ *
+ * <p>It is intended for deserialization from JSON only. It is used for robot comments only.
+ */
 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 7a8e28f..e44d031 100644
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ b/java/com/google/gerrit/server/notedb/Sequences.java
@@ -55,6 +55,9 @@
   private final RepoSequence changeSeq;
   private final RepoSequence groupSeq;
   private final Timer2<SequenceType, Boolean> nextIdLatency;
+  private final int accountBatchSize;
+  private final int changeBatchSize;
+  private final int groupBatchSize = 1;
 
   @Inject
   public Sequences(
@@ -65,7 +68,7 @@
       AllUsersName allUsers,
       MetricMaker metrics) {
 
-    int accountBatchSize =
+    accountBatchSize =
         cfg.getInt(
             SECTION_NOTEDB,
             NAME_ACCOUNTS,
@@ -80,7 +83,7 @@
             () -> FIRST_ACCOUNT_ID,
             accountBatchSize);
 
-    int changeBatchSize =
+    changeBatchSize =
         cfg.getInt(
             SECTION_NOTEDB,
             NAME_CHANGES,
@@ -95,7 +98,6 @@
             () -> FIRST_CHANGE_ID,
             changeBatchSize);
 
-    int groupBatchSize = 1;
     groupSeq =
         new RepoSequence(
             repoManager,
@@ -144,6 +146,18 @@
     }
   }
 
+  public int changeBatchSize() {
+    return changeBatchSize;
+  }
+
+  public int groupBatchSize() {
+    return groupBatchSize;
+  }
+
+  public int accountBatchSize() {
+    return accountBatchSize;
+  }
+
   public int currentChangeId() {
     return changeSeq.current();
   }
@@ -156,6 +170,18 @@
     return groupSeq.current();
   }
 
+  public int lastChangeId() {
+    return changeSeq.last();
+  }
+
+  public int lastGroupId() {
+    return groupSeq.last();
+  }
+
+  public int lastAccountId() {
+    return accountSeq.last();
+  }
+
   public void setChangeIdValue(int value) {
     changeSeq.storeNew(value);
   }
diff --git a/java/com/google/gerrit/server/patch/DiffContentCalculator.java b/java/com/google/gerrit/server/patch/DiffContentCalculator.java
index 53f7ca6..a387da6 100644
--- a/java/com/google/gerrit/server/patch/DiffContentCalculator.java
+++ b/java/com/google/gerrit/server/patch/DiffContentCalculator.java
@@ -14,28 +14,19 @@
 
 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.EditHunk;
 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) {
@@ -60,13 +51,11 @@
    * @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()) {
+      TextSource srcA, TextSource srcB, ImmutableList<Edit> edits) {
+    if (srcA.src == srcB.src && 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.
@@ -80,43 +69,13 @@
       Edit emptyEdit = new Edit(srcA.size(), srcA.size());
       return new DiffCalculatorResult(diffContent, ImmutableList.of(emptyEdit));
     }
-    ImmutableList.Builder<Edit> builder = ImmutableList.builder();
+    ImmutableList<Edit> sortedEdits = correctForDifferencesInNewlineAtEnd(srcA, srcB, edits);
 
-    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);
+        packContent(srcA, srcB, diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE, sortedEdits);
     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.
@@ -205,128 +164,14 @@
     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) {
+      TextSource a, TextSource b, boolean ignoredWhitespace, ImmutableList<Edit> edits) {
     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()) {
+    if (!edits.isEmpty()) {
+      EditHunk hunk = new EditHunk(edits, a.size(), b.size());
       while (hunk.next()) {
-        if (hunk.isContextLine()) {
+        if (hunk.isUnmodifiedLine()) {
           String lineA = a.getSourceLine(hunk.getCurA());
           diffA.addLine(hunk.getCurA(), lineA);
 
diff --git a/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
index eb6a280..b9e644f 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutorModule.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
@@ -31,7 +31,7 @@
   @Provides
   @Singleton
   @DiffExecutor
-  public ExecutorService createDiffExecutor() {
+  public ExecutorService provideDiffExecutor() {
     return new LoggingContextAwareExecutorService(
         Executors.newCachedThreadPool(
             new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build()));
diff --git a/java/com/google/gerrit/server/patch/DiffMappings.java b/java/com/google/gerrit/server/patch/DiffMappings.java
new file mode 100644
index 0000000..921d66e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffMappings.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.Range;
+import com.google.gerrit.server.patch.GitPositionTransformer.RangeMapping;
+
+/** Mappings derived from diffs. */
+public class DiffMappings {
+
+  private DiffMappings() {}
+
+  public static Mapping toMapping(PatchListEntry patchListEntry) {
+    FileMapping fileMapping = toFileMapping(patchListEntry);
+    ImmutableSet<RangeMapping> rangeMappings = toRangeMappings(patchListEntry);
+    return Mapping.create(fileMapping, rangeMappings);
+  }
+
+  private static FileMapping toFileMapping(PatchListEntry patchListEntry) {
+    switch (patchListEntry.getChangeType()) {
+      case ADDED:
+        return FileMapping.forAddedFile(patchListEntry.getNewName());
+      case MODIFIED:
+      case REWRITE:
+        return FileMapping.forModifiedFile(patchListEntry.getNewName());
+      case DELETED:
+        // Name of deleted file is mentioned as newName.
+        return FileMapping.forDeletedFile(patchListEntry.getNewName());
+      case RENAMED:
+      case COPIED:
+        return FileMapping.forRenamedFile(patchListEntry.getOldName(), patchListEntry.getNewName());
+      default:
+        throw new IllegalStateException("Unmapped diff type: " + patchListEntry.getChangeType());
+    }
+  }
+
+  private static ImmutableSet<RangeMapping> toRangeMappings(PatchListEntry patchListEntry) {
+    return patchListEntry.getEdits().stream()
+        .map(
+            edit ->
+                RangeMapping.create(
+                    Range.create(edit.getBeginA(), edit.getEndA()),
+                    Range.create(edit.getBeginB(), edit.getEndB())))
+        .collect(toImmutableSet());
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/EditTransformer.java b/java/com/google/gerrit/server/patch/EditTransformer.java
index 90f442e..6288270 100644
--- a/java/com/google/gerrit/server/patch/EditTransformer.java
+++ b/java/com/google/gerrit/server/patch/EditTransformer.java
@@ -15,19 +15,23 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.collect.Multimaps.toMultimap;
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Multimap;
-import java.util.ArrayList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.OmitPositionOnConflict;
+import com.google.gerrit.server.patch.GitPositionTransformer.Position;
+import com.google.gerrit.server.patch.GitPositionTransformer.PositionedEntity;
+import com.google.gerrit.server.patch.GitPositionTransformer.Range;
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.function.Function;
@@ -42,7 +46,10 @@
  * transformation are omitted.
  */
 class EditTransformer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final GitPositionTransformer positionTransformer =
+      new GitPositionTransformer(OmitPositionOnConflict.INSTANCE);
   private List<ContextAwareEdit> edits;
 
   /**
@@ -105,76 +112,23 @@
   }
 
   private void transformEdits(List<PatchListEntry> transformingEntries, SideStrategy sideStrategy) {
-    Map<String, List<ContextAwareEdit>> editsPerFilePath =
-        edits.stream().collect(groupingBy(sideStrategy::getFilePath));
-    Map<String, List<PatchListEntry>> transEntriesPerPath =
-        transformingEntries.stream().collect(groupingBy(EditTransformer::getOldFilePath));
+    ImmutableList<PositionedEntity<ContextAwareEdit>> positionedEdits =
+        edits.stream()
+            .map(edit -> toPositionedEntity(edit, sideStrategy))
+            .collect(toImmutableList());
+    ImmutableSet<Mapping> mappings =
+        transformingEntries.stream().map(DiffMappings::toMapping).collect(toImmutableSet());
 
     edits =
-        editsPerFilePath.entrySet().stream()
-            .flatMap(
-                pathAndEdits -> {
-                  List<PatchListEntry> transEntries =
-                      transEntriesPerPath.getOrDefault(pathAndEdits.getKey(), ImmutableList.of());
-                  return transformEdits(sideStrategy, pathAndEdits.getValue(), transEntries);
-                })
-            .collect(toList());
+        positionTransformer.transform(positionedEdits, mappings).stream()
+            .map(PositionedEntity::getEntityAtUpdatedPosition)
+            .collect(toImmutableList());
   }
 
-  private static String getOldFilePath(PatchListEntry patchListEntry) {
-    return MoreObjects.firstNonNull(patchListEntry.getOldName(), patchListEntry.getNewName());
-  }
-
-  private static Stream<ContextAwareEdit> transformEdits(
-      SideStrategy sideStrategy,
-      List<ContextAwareEdit> originalEdits,
-      List<PatchListEntry> transformingEntries) {
-    if (transformingEntries.isEmpty()) {
-      return originalEdits.stream();
-    }
-
-    // TODO(aliceks): Find a way to prevent an explosion of the number of entries.
-    return transformingEntries.stream()
-        .flatMap(
-            transEntry ->
-                transformEdits(
-                    sideStrategy, originalEdits, transEntry.getEdits(), transEntry.getNewName())
-                    .stream());
-  }
-
-  private static List<ContextAwareEdit> transformEdits(
-      SideStrategy sideStrategy,
-      List<ContextAwareEdit> unorderedOriginalEdits,
-      List<Edit> unorderedTransformingEdits,
-      String adjustedFilePath) {
-    List<ContextAwareEdit> originalEdits = new ArrayList<>(unorderedOriginalEdits);
-    originalEdits.sort(comparing(sideStrategy::getBegin).thenComparing(sideStrategy::getEnd));
-    List<Edit> transformingEdits = new ArrayList<>(unorderedTransformingEdits);
-    transformingEdits.sort(comparing(Edit::getBeginA).thenComparing(Edit::getEndA));
-
-    int shiftedAmount = 0;
-    int transIndex = 0;
-    int origIndex = 0;
-    List<ContextAwareEdit> resultingEdits = new ArrayList<>(originalEdits.size());
-    while (origIndex < originalEdits.size() && transIndex < transformingEdits.size()) {
-      ContextAwareEdit originalEdit = originalEdits.get(origIndex);
-      Edit transformingEdit = transformingEdits.get(transIndex);
-      if (transformingEdit.getEndA() <= sideStrategy.getBegin(originalEdit)) {
-        shiftedAmount = transformingEdit.getEndB() - transformingEdit.getEndA();
-        transIndex++;
-      } else if (sideStrategy.getEnd(originalEdit) <= transformingEdit.getBeginA()) {
-        resultingEdits.add(sideStrategy.create(originalEdit, shiftedAmount, adjustedFilePath));
-        origIndex++;
-      } else {
-        // Overlapping -> ignore.
-        origIndex++;
-      }
-    }
-    for (int i = origIndex; i < originalEdits.size(); i++) {
-      resultingEdits.add(
-          sideStrategy.create(originalEdits.get(i), shiftedAmount, adjustedFilePath));
-    }
-    return resultingEdits;
+  private static PositionedEntity<ContextAwareEdit> toPositionedEntity(
+      ContextAwareEdit edit, SideStrategy sideStrategy) {
+    return PositionedEntity.create(
+        edit, sideStrategy::extractPosition, sideStrategy::createEditAtNewPosition);
   }
 
   @AutoValue
@@ -191,6 +145,8 @@
     }
 
     static ContextAwareEdit createForNoContentEdit(PatchListEntry patchListEntry) {
+      // Remove the warning in createEditAtNewPosition() if we switch to an empty range instead of
+      // (-1:-1, -1:-1) in the future.
       return create(
           patchListEntry.getOldName(), patchListEntry.getNewName(), -1, -1, -1, -1, false);
     }
@@ -234,44 +190,50 @@
   }
 
   private interface SideStrategy {
-    String getFilePath(ContextAwareEdit edit);
+    Position extractPosition(ContextAwareEdit edit);
 
-    int getBegin(ContextAwareEdit edit);
-
-    int getEnd(ContextAwareEdit edit);
-
-    ContextAwareEdit create(ContextAwareEdit edit, int shiftedAmount, String adjustedFilePath);
+    ContextAwareEdit createEditAtNewPosition(ContextAwareEdit edit, Position newPosition);
   }
 
   private enum SideAStrategy implements SideStrategy {
     INSTANCE;
 
     @Override
-    public String getFilePath(ContextAwareEdit edit) {
-      return edit.getOldFilePath();
+    public Position extractPosition(ContextAwareEdit edit) {
+      return Position.builder()
+          .filePath(edit.getOldFilePath())
+          .lineRange(Range.create(edit.getBeginA(), edit.getEndA()))
+          .build();
     }
 
     @Override
-    public int getBegin(ContextAwareEdit edit) {
-      return edit.getBeginA();
-    }
-
-    @Override
-    public int getEnd(ContextAwareEdit edit) {
-      return edit.getEndA();
-    }
-
-    @Override
-    public ContextAwareEdit create(
-        ContextAwareEdit edit, int shiftedAmount, String adjustedFilePath) {
+    public ContextAwareEdit createEditAtNewPosition(ContextAwareEdit edit, Position newPosition) {
+      // Use an empty range at Gerrit "file level" if no target range is available. Such an empty
+      // range should not occur right now but this should be a safe fallback if something changes
+      // in the future.
+      Range updatedRange = newPosition.lineRange().orElseGet(() -> Range.create(-1, -1));
+      if (!newPosition.lineRange().isPresent()) {
+        logger.atWarning().log(
+            "Position %s has an empty range which is unexpected for the edits-due-to-rebase"
+                + " computation. This is likely a regression!",
+            newPosition);
+      }
+      // Same as for the range above. PATCHSET_LEVEL is a safe fallback.
+      String updatedFilePath = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL);
+      if (!newPosition.filePath().isPresent()) {
+        logger.atWarning().log(
+            "Position %s has an empty file path which is unexpected for the edits-due-to-rebase"
+                + " computation. This is likely a regression!",
+            newPosition);
+      }
       return ContextAwareEdit.create(
-          adjustedFilePath,
+          updatedFilePath,
           edit.getNewFilePath(),
-          edit.getBeginA() + shiftedAmount,
-          edit.getEndA() + shiftedAmount,
+          updatedRange.start(),
+          updatedRange.end(),
           edit.getBeginB(),
           edit.getEndB(),
-          !Objects.equals(edit.getOldFilePath(), adjustedFilePath));
+          !Objects.equals(edit.getOldFilePath(), updatedFilePath));
     }
   }
 
@@ -279,31 +241,29 @@
     INSTANCE;
 
     @Override
-    public String getFilePath(ContextAwareEdit edit) {
-      return edit.getNewFilePath();
+    public Position extractPosition(ContextAwareEdit edit) {
+      return Position.builder()
+          .filePath(edit.getNewFilePath())
+          .lineRange(Range.create(edit.getBeginB(), edit.getEndB()))
+          .build();
     }
 
     @Override
-    public int getBegin(ContextAwareEdit edit) {
-      return edit.getBeginB();
-    }
-
-    @Override
-    public int getEnd(ContextAwareEdit edit) {
-      return edit.getEndB();
-    }
-
-    @Override
-    public ContextAwareEdit create(
-        ContextAwareEdit edit, int shiftedAmount, String adjustedFilePath) {
+    public ContextAwareEdit createEditAtNewPosition(ContextAwareEdit edit, Position newPosition) {
+      // Use an empty range at Gerrit "file level" if no target range is available. Such an empty
+      // range should not occur right now but this should be a safe fallback if something changes
+      // in the future.
+      Range updatedRange = newPosition.lineRange().orElseGet(() -> Range.create(-1, -1));
+      // Same as far the range above. PATCHSET_LEVEL is a safe fallback.
+      String updatedFilePath = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL);
       return ContextAwareEdit.create(
           edit.getOldFilePath(),
-          adjustedFilePath,
+          updatedFilePath,
           edit.getBeginA(),
           edit.getEndA(),
-          edit.getBeginB() + shiftedAmount,
-          edit.getEndB() + shiftedAmount,
-          !Objects.equals(edit.getNewFilePath(), adjustedFilePath));
+          updatedRange.start(),
+          updatedRange.end(),
+          !Objects.equals(edit.getNewFilePath(), updatedFilePath));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/patch/GitPositionTransformer.java b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
new file mode 100644
index 0000000..d890bc2
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
@@ -0,0 +1,643 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.collect.Comparators.emptiesFirst;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Transformer of {@link Position}s in one Git tree to {@link Position}s in another Git tree given
+ * the {@link Mapping}s between the trees.
+ *
+ * <p>The base idea is that a {@link Position} in the source tree can be translated/mapped to a
+ * corresponding {@link Position} in the target tree when we know how the target tree changed
+ * compared to the source tree. As long as {@link Position}s are only defined via file path and line
+ * range, we only need to know which file path in the source tree corresponds to which file path in
+ * the target tree and how the lines within that file changed from the source to the target tree.
+ *
+ * <p>The algorithm is roughly:
+ *
+ * <ol>
+ *   <li>Go over all positions and replace the file path for each of them with the corresponding one
+ *       in the target tree. If a file path maps to two file paths in the target tree (copied file),
+ *       duplicate the position entry and use each of the new file paths with it. If a file path
+ *       maps to no file in the target tree (deleted file), apply the specified conflict strategy
+ *       (e.g. drop position completely or map to next best guess).
+ *   <li>Per file path, go through the file from top to bottom and keep track of how the range
+ *       mappings for that file shift the lines. Derive the shifted amount by comparing the number
+ *       of lines between source and target in the range mapping. While going through the file,
+ *       shift each encountered position by the currently tracked amount. If a position overlaps
+ *       with the lines of a range mapping, apply the specified conflict strategy (e.g. drop
+ *       position completely or map to next best guess).
+ * </ol>
+ */
+public class GitPositionTransformer {
+  private final PositionConflictStrategy positionConflictStrategy;
+
+  /**
+   * Creates a new {@code GitPositionTransformer} which uses the specified strategy for conflicts.
+   */
+  public GitPositionTransformer(PositionConflictStrategy positionConflictStrategy) {
+    this.positionConflictStrategy = positionConflictStrategy;
+  }
+
+  /**
+   * Transforms the {@link Position}s of the specified entities as indicated via the {@link
+   * Mapping}s.
+   *
+   * <p>This is typically used to transform the {@link Position}s in one Git tree (source) to the
+   * corresponding {@link Position}s in another Git tree (target). The {@link Mapping}s need to
+   * indicate all relevant changes between the source and target tree. {@link Mapping}s for files
+   * not referenced by the given {@link Position}s need not be specified. They can be included,
+   * though, as they aren't harmful.
+   *
+   * @param entities the entities whose {@link Position} should be mapped to the target tree
+   * @param mappings the mappings describing all relevant changes between the source and the target
+   *     tree
+   * @param <T> an entity which has a {@link Position}
+   * @return a list of entities with transformed positions. There are no guarantees about the order
+   *     of the returned elements.
+   */
+  public <T> ImmutableList<PositionedEntity<T>> transform(
+      Collection<PositionedEntity<T>> entities, Set<Mapping> mappings) {
+    // Update the file paths first as copied files might exist. For copied files, this operation
+    // will duplicate the PositionedEntity instances of the original file.
+    List<PositionedEntity<T>> filePathUpdatedEntities = updateFilePaths(entities, mappings);
+
+    return shiftRanges(filePathUpdatedEntities, mappings);
+  }
+
+  private <T> ImmutableList<PositionedEntity<T>> updateFilePaths(
+      Collection<PositionedEntity<T>> entities, Set<Mapping> mappings) {
+    Map<String, ImmutableSet<String>> newFilesPerOldFile = groupNewFilesByOldFiles(mappings);
+    return entities.stream()
+        .flatMap(entity -> mapToNewFileIfChanged(newFilesPerOldFile, entity))
+        .collect(toImmutableList());
+  }
+
+  private static Map<String, ImmutableSet<String>> groupNewFilesByOldFiles(Set<Mapping> mappings) {
+    return mappings.stream()
+        .map(Mapping::file)
+        // Ignore file additions (irrelevant for mappings).
+        .filter(mapping -> mapping.oldPath().isPresent())
+        .collect(
+            groupingBy(
+                mapping -> mapping.oldPath().orElse(""),
+                collectingAndThen(
+                    Collectors.mapping(FileMapping::newPath, toImmutableSet()),
+                    // File deletion (empty Optional) -> empty set.
+                    GitPositionTransformer::unwrapOptionals)));
+  }
+
+  private static ImmutableSet<String> unwrapOptionals(ImmutableSet<Optional<String>> optionals) {
+    return optionals.stream().flatMap(Streams::stream).collect(toImmutableSet());
+  }
+
+  private <T> Stream<PositionedEntity<T>> mapToNewFileIfChanged(
+      Map<String, ? extends Set<String>> newFilesPerOldFile, PositionedEntity<T> entity) {
+    if (!entity.position().filePath().isPresent()) {
+      // No mapping of file paths necessary if no file path is set. -> Keep existing entry.
+      return Stream.of(entity);
+    }
+    String oldFilePath = entity.position().filePath().get();
+    if (!newFilesPerOldFile.containsKey(oldFilePath)) {
+      // Unchanged files don't have a mapping. -> Keep existing entries.
+      return Stream.of(entity);
+    }
+    Set<String> newFiles = newFilesPerOldFile.get(oldFilePath);
+    if (newFiles.isEmpty()) {
+      // File was deleted.
+      return Streams.stream(
+          positionConflictStrategy.getOnFileConflict(entity.position()).map(entity::withPosition));
+    }
+    return newFiles.stream().map(entity::withFilePath);
+  }
+
+  private <T> ImmutableList<PositionedEntity<T>> shiftRanges(
+      List<PositionedEntity<T>> filePathUpdatedEntities, Set<Mapping> mappings) {
+    Map<String, ImmutableSet<RangeMapping>> mappingsPerNewFilePath =
+        groupRangeMappingsByNewFilePath(mappings);
+    return Stream.concat(
+            // Keep positions without a file.
+            filePathUpdatedEntities.stream()
+                .filter(entity -> !entity.position().filePath().isPresent()),
+            // Shift ranges per file.
+            groupByFilePath(filePathUpdatedEntities).entrySet().stream()
+                .flatMap(
+                    newFilePathAndEntities ->
+                        shiftRangesInOneFileIfChanged(
+                            mappingsPerNewFilePath,
+                            newFilePathAndEntities.getKey(),
+                            newFilePathAndEntities.getValue())
+                            .stream()))
+        .collect(toImmutableList());
+  }
+
+  private static Map<String, ImmutableSet<RangeMapping>> groupRangeMappingsByNewFilePath(
+      Set<Mapping> mappings) {
+    return mappings.stream()
+        // Ignore range mappings of deleted files.
+        .filter(mapping -> mapping.file().newPath().isPresent())
+        .collect(
+            groupingBy(
+                mapping -> mapping.file().newPath().orElse(""),
+                collectingAndThen(
+                    Collectors.<Mapping, Set<RangeMapping>>reducing(
+                        new HashSet<>(), Mapping::ranges, Sets::union),
+                    ImmutableSet::copyOf)));
+  }
+
+  private static <T> Map<String, ImmutableList<PositionedEntity<T>>> groupByFilePath(
+      List<PositionedEntity<T>> fileUpdatedEntities) {
+    return fileUpdatedEntities.stream()
+        .filter(entity -> entity.position().filePath().isPresent())
+        .collect(groupingBy(entity -> entity.position().filePath().orElse(""), toImmutableList()));
+  }
+
+  private <T> ImmutableList<PositionedEntity<T>> shiftRangesInOneFileIfChanged(
+      Map<String, ImmutableSet<RangeMapping>> mappingsPerNewFilePath,
+      String newFilePath,
+      ImmutableList<PositionedEntity<T>> sameFileEntities) {
+    ImmutableSet<RangeMapping> sameFileRangeMappings =
+        mappingsPerNewFilePath.getOrDefault(newFilePath, ImmutableSet.of());
+    if (sameFileRangeMappings.isEmpty()) {
+      // Unchanged files and pure renames/copies don't have range mappings. -> Keep existing
+      // entries.
+      return sameFileEntities;
+    }
+    return shiftRangesInOneFile(sameFileEntities, sameFileRangeMappings);
+  }
+
+  private <T> ImmutableList<PositionedEntity<T>> shiftRangesInOneFile(
+      List<PositionedEntity<T>> sameFileEntities, Set<RangeMapping> sameFileRangeMappings) {
+    ImmutableList<PositionedEntity<T>> sortedEntities = sortByStartEnd(sameFileEntities);
+    ImmutableList<RangeMapping> sortedMappings = sortByOldStartEnd(sameFileRangeMappings);
+
+    int shiftedAmount = 0;
+    int mappingIndex = 0;
+    int entityIndex = 0;
+    ImmutableList.Builder<PositionedEntity<T>> resultingEntities =
+        ImmutableList.builderWithExpectedSize(sortedEntities.size());
+    while (entityIndex < sortedEntities.size() && mappingIndex < sortedMappings.size()) {
+      PositionedEntity<T> entity = sortedEntities.get(entityIndex);
+      if (entity.position().lineRange().isPresent()) {
+        Range range = entity.position().lineRange().get();
+        RangeMapping mapping = sortedMappings.get(mappingIndex);
+        if (mapping.oldLineRange().end() <= range.start()) {
+          shiftedAmount = mapping.newLineRange().end() - mapping.oldLineRange().end();
+          mappingIndex++;
+        } else if (range.end() <= mapping.oldLineRange().start()) {
+          resultingEntities.add(entity.shiftPositionBy(shiftedAmount));
+          entityIndex++;
+        } else {
+          positionConflictStrategy
+              .getOnRangeConflict(entity.position())
+              .map(entity::withPosition)
+              .ifPresent(resultingEntities::add);
+          entityIndex++;
+        }
+      } else {
+        // No range -> no need to shift.
+        resultingEntities.add(entity);
+        entityIndex++;
+      }
+    }
+    for (int i = entityIndex; i < sortedEntities.size(); i++) {
+      resultingEntities.add(sortedEntities.get(i).shiftPositionBy(shiftedAmount));
+    }
+    return resultingEntities.build();
+  }
+
+  private static <T> ImmutableList<PositionedEntity<T>> sortByStartEnd(
+      List<PositionedEntity<T>> entities) {
+    return entities.stream()
+        .sorted(
+            comparing(
+                entity -> entity.position().lineRange(),
+                emptiesFirst(comparing(Range::start).thenComparing(Range::end))))
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<RangeMapping> sortByOldStartEnd(Set<RangeMapping> mappings) {
+    return mappings.stream()
+        .sorted(
+            comparing(
+                RangeMapping::oldLineRange, comparing(Range::start).thenComparing(Range::end)))
+        .collect(toImmutableList());
+  }
+
+  /**
+   * A mapping from a {@link Position} in one Git commit/tree (source) to a {@link Position} in
+   * another Git commit/tree (target).
+   */
+  @AutoValue
+  public abstract static class Mapping {
+
+    /** A mapping describing how the attributes of one file are mapped from source to target. */
+    public abstract FileMapping file();
+
+    /**
+     * Mappings describing how line ranges within the file indicated by {@link #file()} are mapped
+     * from source to target.
+     */
+    public abstract ImmutableSet<RangeMapping> ranges();
+
+    public static Mapping create(FileMapping fileMapping, Iterable<RangeMapping> rangeMappings) {
+      return new AutoValue_GitPositionTransformer_Mapping(
+          fileMapping, ImmutableSet.copyOf(rangeMappings));
+    }
+  }
+
+  /**
+   * A mapping of attributes from a file in one Git tree (source) to a file in another Git tree
+   * (target).
+   *
+   * <p>At the moment, only the file path is considered. Other attributes like file mode would be
+   * imaginable too but are currently not supported.
+   */
+  @AutoValue
+  public abstract static class FileMapping {
+
+    /** File path in the source tree. For file additions, this is an empty {@link Optional}. */
+    public abstract Optional<String> oldPath();
+
+    /**
+     * File path in the target tree. Can be the same as {@link #oldPath()} if unchanged. For file
+     * deletions, this is an empty {@link Optional}.
+     */
+    public abstract Optional<String> newPath();
+
+    /**
+     * Creates a {@link FileMapping} for a file addition.
+     *
+     * <p>In the context of {@link GitPositionTransformer}, file additions are irrelevant as no
+     * given position in the source tree can refer to such a new file in the target tree. We still
+     * provide this factory method so that code outside of {@link GitPositionTransformer} doesn't
+     * have to care about such details and can simply create {@link FileMapping}s for any
+     * modifications between the trees.
+     */
+    public static FileMapping forAddedFile(String filePath) {
+      return new AutoValue_GitPositionTransformer_FileMapping(
+          Optional.empty(), Optional.of(filePath));
+    }
+
+    /** Creates a {@link FileMapping} for a file deletion. */
+    public static FileMapping forDeletedFile(String filePath) {
+      return new AutoValue_GitPositionTransformer_FileMapping(
+          Optional.of(filePath), Optional.empty());
+    }
+
+    /** Creates a {@link FileMapping} for a file modification. */
+    public static FileMapping forModifiedFile(String filePath) {
+      return new AutoValue_GitPositionTransformer_FileMapping(
+          Optional.of(filePath), Optional.of(filePath));
+    }
+
+    /** Creates a {@link FileMapping} for a file renaming. */
+    public static FileMapping forRenamedFile(String oldPath, String newPath) {
+      return new AutoValue_GitPositionTransformer_FileMapping(
+          Optional.of(oldPath), Optional.of(newPath));
+    }
+  }
+
+  /**
+   * A mapping of a line range in one Git tree (source) to the corresponding line range in another
+   * Git tree (target).
+   */
+  @AutoValue
+  public abstract static class RangeMapping {
+
+    /** Range in the source tree. */
+    public abstract Range oldLineRange();
+
+    /** Range in the target tree. */
+    public abstract Range newLineRange();
+
+    /**
+     * Creates a new {@code RangeMapping}.
+     *
+     * @param oldRange see {@link #oldLineRange()}
+     * @param newRange see {@link #newLineRange()}
+     */
+    public static RangeMapping create(Range oldRange, Range newRange) {
+      return new AutoValue_GitPositionTransformer_RangeMapping(oldRange, newRange);
+    }
+  }
+
+  /**
+   * A position within the tree of a Git commit.
+   *
+   * <p>The term 'position' is our own invention. The underlying idea is that a Gerrit comment is at
+   * a specific position within the commit of a patchset. That position is defined by the attributes
+   * defined in this class.
+   *
+   * <p>The same thinking can be applied to diff hunks (= JGit edits). Each diff hunk maps a
+   * position in one commit (e.g. in the parent of the patchset) to a position in another commit
+   * (e.g. in the commit of the patchset).
+   *
+   * <p>We only refer to lines and not character offsets within the lines here as Git only works
+   * with line precision. In theory, we could do better in Gerrit as we also have intraline diffs.
+   * Incorporating those requires careful considerations, though.
+   */
+  @AutoValue
+  public abstract static class Position {
+
+    /** Absolute file path. */
+    public abstract Optional<String> filePath();
+
+    /**
+     * Affected lines. An empty {@link Optional} indicates that this position does not refer to any
+     * specific lines (e.g. used for a file comment).
+     */
+    public abstract Optional<Range> lineRange();
+
+    /**
+     * Creates a copy of this {@code Position} whose range is shifted by the indicated amount.
+     *
+     * <p><strong>Note:</strong> There's no guarantee that this method returns a new instance.
+     *
+     * @param amount number of lines to shift. Negative values mean moving the range up, positive
+     *     values mean moving the range down.
+     * @return a {@code Position} instance with the updated range
+     */
+    public Position shiftBy(int amount) {
+      return lineRange()
+          .map(range -> toBuilder().lineRange(range.shiftBy(amount)).build())
+          .orElse(this);
+    }
+
+    /**
+     * Creates a copy of this {@code Position} which doesn't refer to any specific lines.
+     *
+     * <p><strong>Note:</strong> There's no guarantee that this method returns a new instance.
+     *
+     * @return a {@code Position} instance without a line range
+     */
+    public Position withoutLineRange() {
+      return toBuilder().lineRange(Optional.empty()).build();
+    }
+
+    /**
+     * Creates a copy of this {@code Position} whose file path is adjusted to the indicated value.
+     *
+     * <p><strong>Note:</strong> There's no guarantee that this method returns a new instance.
+     *
+     * @param filePath the new file path to use
+     * @return a {@code Position} instance with the indicated file path
+     */
+    public Position withFilePath(String filePath) {
+      return toBuilder().filePath(filePath).build();
+    }
+
+    abstract Builder toBuilder();
+
+    public static Builder builder() {
+      return new AutoValue_GitPositionTransformer_Position.Builder();
+    }
+
+    /** Builder of a {@link Position}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      /** See {@link #filePath()}. */
+      public abstract Builder filePath(String filePath);
+
+      /** See {@link #lineRange()}. */
+      public abstract Builder lineRange(Range lineRange);
+
+      /** See {@link #lineRange()}. */
+      public abstract Builder lineRange(Optional<Range> lineRange);
+
+      public abstract Position build();
+    }
+  }
+
+  /** A range. In the context of {@link GitPositionTransformer}, this is a line range. */
+  @AutoValue
+  public abstract static class Range {
+
+    /** Start of the range. (inclusive) */
+    public abstract int start();
+
+    /** End of the range. (exclusive) */
+    public abstract int end();
+
+    /**
+     * Creates a copy of this {@code Range} which is shifted by the indicated amount. A shift
+     * equally applies to both {@link #start()} end {@link #end()}.
+     *
+     * <p><strong>Note:</strong> There's no guarantee that this method returns a new instance.
+     *
+     * @param amount amount to shift. Negative values mean moving the range up, positive values mean
+     *     moving the range down.
+     * @return a {@code Range} instance with updated start/end
+     */
+    public Range shiftBy(int amount) {
+      return create(start() + amount, end() + amount);
+    }
+
+    public static Range create(int start, int end) {
+      return new AutoValue_GitPositionTransformer_Range(start, end);
+    }
+  }
+
+  /**
+   * Wrapper around an instance of {@code T} which annotates it with a {@link Position}. Methods
+   * such as {@link #shiftPositionBy(int)} and {@link #withFilePath(String)} allow to update the
+   * associated {@link Position}. Afterwards, use {@link #getEntityAtUpdatedPosition()} to get an
+   * updated version of the {@code T} instance.
+   *
+   * @param <T> an object/entity type which has a {@link Position}
+   */
+  public static class PositionedEntity<T> {
+
+    private final T entity;
+    private final Position position;
+    private final BiFunction<T, Position, T> updatedEntityCreator;
+
+    /**
+     * Creates a new {@code PositionedEntity}.
+     *
+     * @param entity an instance which should be annotated with a {@link Position}
+     * @param positionExtractor a function describing how a {@link Position} can be derived from the
+     *     given entity
+     * @param updatedEntityCreator a function to create a new entity of type {@code T} from an
+     *     existing entity and a given {@link Position}. This must return a new instance of type
+     *     {@code T}! The existing instance must not be modified!
+     * @param <T> an object/entity type which has a {@link Position}
+     */
+    public static <T> PositionedEntity<T> create(
+        T entity,
+        Function<T, Position> positionExtractor,
+        BiFunction<T, Position, T> updatedEntityCreator) {
+      Position position = positionExtractor.apply(entity);
+      return new PositionedEntity<>(entity, position, updatedEntityCreator);
+    }
+
+    private PositionedEntity(
+        T entity, Position position, BiFunction<T, Position, T> updatedEntityCreator) {
+      this.entity = entity;
+      this.position = position;
+      this.updatedEntityCreator = updatedEntityCreator;
+    }
+
+    /**
+     * Returns an updated version of the entity to which the internally stored {@link Position} was
+     * written back to.
+     *
+     * @return an updated instance of {@code T}
+     */
+    public T getEntityAtUpdatedPosition() {
+      return updatedEntityCreator.apply(entity, position);
+    }
+
+    Position position() {
+      return position;
+    }
+
+    /**
+     * Shifts the tracked {@link Position} by the specified amount.
+     *
+     * @param amount number of lines to shift. Negative values mean moving the range up, positive
+     *     values mean moving the range down.
+     * @return a {@code PositionedEntity} with updated {@link Position}
+     */
+    public PositionedEntity<T> shiftPositionBy(int amount) {
+      return new PositionedEntity<>(entity, position.shiftBy(amount), updatedEntityCreator);
+    }
+
+    /**
+     * Updates the file path of the tracked {@link Position}.
+     *
+     * @param filePath the new file path to use
+     * @return a {@code PositionedEntity} with updated {@link Position}
+     */
+    public PositionedEntity<T> withFilePath(String filePath) {
+      return new PositionedEntity<>(entity, position.withFilePath(filePath), updatedEntityCreator);
+    }
+
+    /**
+     * Updates the tracked {@link Position}.
+     *
+     * @return a {@code PositionedEntity} with updated {@link Position}
+     */
+    public PositionedEntity<T> withPosition(Position newPosition) {
+      return new PositionedEntity<>(entity, newPosition, updatedEntityCreator);
+    }
+  }
+
+  /**
+   * Strategy indicating how to handle {@link Position}s for which mapping conflicts exist. A
+   * mapping conflict means that a {@link Position} can't be transformed such that it still refers
+   * to exactly the same commit content afterwards.
+   *
+   * <p>Example: A {@link Position} refers to file foo.txt and lines 5-6 which contain the text
+   * "Line 5\nLine 6". One of the {@link Mapping}s given to {@link #transform(Collection, Set)}
+   * indicates that line 5 of foo.txt was modified to "Line five\nLine 5.1\n". We could derive a
+   * transformed {@link Position} (foo.txt, lines 5-7) but that {@link Position} would then refer to
+   * the content "Line five\nLine 5.1\nLine 6". If the modification started already in line 4, we
+   * could even only guess what the transformed {@link Position} would be.
+   */
+  public interface PositionConflictStrategy {
+    /**
+     * Determines an alternate {@link Position} when the range of the position can't be mapped
+     * without a conflict.
+     *
+     * @param oldPosition position in the source tree
+     * @return the new {@link Position} or an empty {@link Optional} if the position should be
+     *     dropped
+     */
+    Optional<Position> getOnRangeConflict(Position oldPosition);
+
+    /**
+     * Determines an alternate {@link Position} when there is no file for the position (= file
+     * deletion) in the target tree.
+     *
+     * @param oldPosition position in the source tree
+     * @return the new {@link Position} or an empty {@link Optional} if the position should be *
+     *     dropped
+     */
+    Optional<Position> getOnFileConflict(Position oldPosition);
+  }
+
+  /**
+   * A strategy which drops any {@link Position}s on a conflicting mapping. Such a strategy is
+   * useful if it's important that any mapped {@link Position} still refers to exactly the same
+   * commit content as before. See more details at {@link PositionConflictStrategy}.
+   *
+   * <p>We need this strategy for computing edits due to rebase.
+   */
+  public enum OmitPositionOnConflict implements PositionConflictStrategy {
+    INSTANCE;
+
+    @Override
+    public Optional<Position> getOnRangeConflict(Position oldPosition) {
+      return Optional.empty();
+    }
+
+    @Override
+    public Optional<Position> getOnFileConflict(Position oldPosition) {
+      return Optional.empty();
+    }
+  }
+
+  /**
+   * A strategy which tries to select the next suitable {@link Position} on a conflicting mapping.
+   * At the moment, this strategy is very basic and only defers to the next higher level (e.g. range
+   * unclear -> drop range but keep file reference). This could be improved in the future.
+   *
+   * <p>We need this strategy for ported comments.
+   *
+   * <p><strong>Warning:</strong> With this strategy, mapped {@link Position}s are not guaranteed to
+   * refer to exactly the same commit content as before. See more details at {@link
+   * PositionConflictStrategy}.
+   *
+   * <p>Contract: This strategy will never drop any {@link Position}.
+   */
+  public enum BestPositionOnConflict implements PositionConflictStrategy {
+    INSTANCE;
+
+    @Override
+    public Optional<Position> getOnRangeConflict(Position oldPosition) {
+      return Optional.of(oldPosition.withoutLineRange());
+    }
+
+    @Override
+    public Optional<Position> getOnFileConflict(Position oldPosition) {
+      // If there isn't a target file, we can also drop any ranges.
+      return Optional.of(Position.builder().build());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/MagicFile.java b/java/com/google/gerrit/server/patch/MagicFile.java
new file mode 100644
index 0000000..aa6b11f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/MagicFile.java
@@ -0,0 +1,189 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.gerrit.git.ObjectIds;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Representation of a magic file which appears as a file with content to Gerrit users. */
+@AutoValue
+public abstract class MagicFile {
+
+  public static MagicFile forCommitMessage(ObjectReader reader, AnyObjectId commitId)
+      throws IOException {
+    try (RevWalk rw = new RevWalk(reader)) {
+      RevCommit c;
+      if (commitId instanceof RevCommit) {
+        c = (RevCommit) commitId;
+      } else {
+        c = rw.parseCommit(commitId);
+      }
+
+      String header = createCommitMessageHeader(reader, rw, c);
+      String message = c.getFullMessage();
+      return MagicFile.builder().generatedContent(header).modifiableContent(message).build();
+    }
+  }
+
+  private static String createCommitMessageHeader(ObjectReader reader, RevWalk rw, RevCommit c)
+      throws IOException {
+    StringBuilder b = new StringBuilder();
+    switch (c.getParentCount()) {
+      case 0:
+        break;
+      case 1:
+        {
+          RevCommit p = c.getParent(0);
+          rw.parseBody(p);
+          b.append("Parent:     ");
+          b.append(abbreviateName(p, reader));
+          b.append(" (");
+          b.append(p.getShortMessage());
+          b.append(")\n");
+          break;
+        }
+      default:
+        for (int i = 0; i < c.getParentCount(); i++) {
+          RevCommit p = c.getParent(i);
+          rw.parseBody(p);
+          b.append(i == 0 ? "Merge Of:   " : "            ");
+          b.append(abbreviateName(p, reader));
+          b.append(" (");
+          b.append(p.getShortMessage());
+          b.append(")\n");
+        }
+    }
+    appendPersonIdent(b, "Author", c.getAuthorIdent());
+    appendPersonIdent(b, "Commit", c.getCommitterIdent());
+    b.append("\n");
+    return b.toString();
+  }
+
+  public static MagicFile forMergeList(
+      ComparisonType comparisonType, ObjectReader reader, AnyObjectId commitId) throws IOException {
+    try (RevWalk rw = new RevWalk(reader)) {
+      RevCommit c = rw.parseCommit(commitId);
+      StringBuilder b = new StringBuilder();
+      switch (c.getParentCount()) {
+        case 0:
+          break;
+        case 1:
+          {
+            break;
+          }
+        default:
+          int uninterestingParent =
+              comparisonType.isAgainstParent() ? comparisonType.getParentNum() : 1;
+
+          b.append("Merge List:\n\n");
+          for (RevCommit commit : MergeListBuilder.build(rw, c, uninterestingParent)) {
+            b.append("* ");
+            b.append(abbreviateName(commit, reader));
+            b.append(" ");
+            b.append(commit.getShortMessage());
+            b.append("\n");
+          }
+      }
+      return MagicFile.builder().generatedContent(b.toString()).build();
+    }
+  }
+
+  private static String abbreviateName(RevCommit p, ObjectReader reader) throws IOException {
+    return ObjectIds.abbreviateName(p, 8, reader);
+  }
+
+  private static void appendPersonIdent(StringBuilder b, String field, PersonIdent person) {
+    if (person != null) {
+      b.append(field).append(":    ");
+      if (person.getName() != null) {
+        b.append(" ");
+        b.append(person.getName());
+      }
+      if (person.getEmailAddress() != null) {
+        b.append(" <");
+        b.append(person.getEmailAddress());
+        b.append(">");
+      }
+      b.append("\n");
+
+      SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss ZZZ");
+      sdf.setTimeZone(person.getTimeZone());
+      b.append(field).append("Date: ");
+      b.append(sdf.format(person.getWhen()));
+      b.append("\n");
+    }
+  }
+
+  /** Generated part of the file. Any generated contents should go here. Can be empty. */
+  public abstract String generatedContent();
+
+  /**
+   * Non-generated part of the file. This should correspond to some actual content derived from
+   * somewhere else which can also be modified (e.g. by suggested fixes). Can be empty.
+   */
+  public abstract String modifiableContent();
+
+  /** Whole content of the file as it appears to users. */
+  public String getFileContent() {
+    return generatedContent() + modifiableContent();
+  }
+
+  /** Returns the start line of the modifiable content. Assumes that line counting starts at 1. */
+  public int getStartLineOfModifiableContent() {
+    int numHeaderLines = CharMatcher.is('\n').countIn(generatedContent());
+    // Lines start at 1 and not 0. -> Add 1.
+    return 1 + numHeaderLines;
+  }
+
+  static Builder builder() {
+    return new AutoValue_MagicFile.Builder().generatedContent("").modifiableContent("");
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+
+    /** See {@link #generatedContent()}. Use an empty string to denote no such content. */
+    public abstract Builder generatedContent(String content);
+
+    /** See {@link #modifiableContent()}. Use an empty string to denote no such content. */
+    public abstract Builder modifiableContent(String content);
+
+    abstract String generatedContent();
+
+    abstract String modifiableContent();
+
+    abstract MagicFile autoBuild();
+
+    public MagicFile build() {
+      // Normalize each content part to end with a newline character, which simplifies further
+      // handling.
+      if (!generatedContent().isEmpty() && !generatedContent().endsWith("\n")) {
+        generatedContent(generatedContent() + "\n");
+      }
+      if (!modifiableContent().isEmpty() && !modifiableContent().endsWith("\n")) {
+        modifiableContent(modifiableContent() + "\n");
+      }
+      return autoBuild();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index c9e45ba..28f61d3 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -45,23 +45,12 @@
 public class PatchList implements Serializable {
   private static final long serialVersionUID = PatchListKey.serialVersionUID;
 
-  private static final Comparator<PatchListEntry> PATCH_CMP =
-      Comparator.comparing(PatchListEntry::getNewName, PatchList::comparePaths);
-
   @VisibleForTesting
-  static int comparePaths(String a, String b) {
-    int m1 = Patch.isMagic(a) ? (a.equals(Patch.MERGE_LIST) ? 2 : 1) : 3;
-    int m2 = Patch.isMagic(b) ? (b.equals(Patch.MERGE_LIST) ? 2 : 1) : 3;
+  static final Comparator<String> FILE_PATH_CMP =
+      Comparator.comparing(Patch::isMagic).reversed().thenComparing(Comparator.naturalOrder());
 
-    if (m1 != m2) {
-      return m1 - m2;
-    } else if (m1 < 3) {
-      return 0;
-    }
-
-    // m1 == m2 == 3: normal names.
-    return a.compareTo(b);
-  }
+  private static final Comparator<PatchListEntry> PATCH_CMP =
+      Comparator.comparing(PatchListEntry::getNewName, FILE_PATH_CMP);
 
   @Nullable private transient ObjectId oldId;
   private transient ObjectId newId;
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index c3d9a1d..be0895b 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -26,10 +26,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -41,6 +43,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
@@ -59,6 +62,7 @@
 import org.eclipse.jgit.diff.HistogramDiff;
 import org.eclipse.jgit.diff.RawText;
 import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
@@ -383,8 +387,20 @@
       Set<ContextAwareEdit> editsDueToRebase)
       throws IOException {
     FileHeader fileHeader = toFileHeader(key.getNewId(), diffFormatter, diffEntry);
-    long oldSize = getFileSize(objectReader, diffEntry.getOldMode(), diffEntry.getOldPath(), treeA);
-    long newSize = getFileSize(objectReader, diffEntry.getNewMode(), diffEntry.getNewPath(), treeB);
+    long oldSize =
+        getFileSize(
+            objectReader,
+            diffEntry.getOldId(),
+            diffEntry.getOldMode(),
+            diffEntry.getOldPath(),
+            treeA);
+    long newSize =
+        getFileSize(
+            objectReader,
+            diffEntry.getNewId(),
+            diffEntry.getNewMode(),
+            diffEntry.getNewPath(),
+            treeB);
     Set<Edit> contentEditsDueToRebase = getContentEdits(editsDueToRebase);
     PatchListEntry patchListEntry =
         newEntry(treeA, fileHeader, contentEditsDueToRebase, newSize, newSize - oldSize);
@@ -417,14 +433,18 @@
     return ComparisonType.againstOtherPatchSet();
   }
 
-  private static long getFileSize(ObjectReader reader, FileMode mode, String path, RevTree t)
+  private static long getFileSize(
+      ObjectReader reader, AbbreviatedObjectId abbreviatedId, FileMode mode, String path, RevTree t)
       throws IOException {
     if (!isBlob(mode)) {
       return 0;
     }
-    try (TreeWalk tw = TreeWalk.forPath(reader, path, t)) {
-      return tw != null ? reader.open(tw.getObjectId(0), OBJ_BLOB).getSize() : 0;
+    ObjectId fileId =
+        toObjectId(reader, abbreviatedId).orElseGet(() -> lookupObjectId(reader, path, t));
+    if (ObjectId.zeroId().equals(fileId)) {
+      return 0;
     }
+    return reader.getObjectSize(fileId, OBJ_BLOB);
   }
 
   private static boolean isBlob(FileMode mode) {
@@ -432,6 +452,37 @@
     return t == FileMode.TYPE_FILE || t == FileMode.TYPE_SYMLINK;
   }
 
+  private static Optional<ObjectId> toObjectId(
+      ObjectReader reader, AbbreviatedObjectId abbreviatedId) throws IOException {
+    if (abbreviatedId == null) {
+      // In theory, DiffEntry#getOldId or DiffEntry#getNewId can be null for pure renames or pure
+      // mode changes (e.g. DiffEntry#modify doesn't set the IDs). However, the method we call
+      // for diffs (DiffFormatter#scan) seems to always produce DiffEntries with set IDs, even for
+      // pure renames.
+      return Optional.empty();
+    }
+    if (abbreviatedId.isComplete()) {
+      // With the current JGit version and the method we call for diffs (DiffFormatter#scan), this
+      // is the only code path taken right now.
+      return Optional.ofNullable(abbreviatedId.toObjectId());
+    }
+    Collection<ObjectId> objectIds = reader.resolve(abbreviatedId);
+    // It seems very unlikely that an ObjectId which was just abbreviated by the diff computation
+    // now can't be resolved to exactly one ObjectId. The API allows this possibility, though.
+    return objectIds.size() == 1
+        ? Optional.of(Iterables.getOnlyElement(objectIds))
+        : Optional.empty();
+  }
+
+  private static ObjectId lookupObjectId(ObjectReader reader, String path, RevTree tree) {
+    // This variant is very expensive.
+    try (TreeWalk treeWalk = TreeWalk.forPath(reader, path, tree)) {
+      return treeWalk != null ? treeWalk.getObjectId(0) : ObjectId.zeroId();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
   private FileHeader toFileHeader(
       ObjectId commitB, DiffFormatter diffFormatter, DiffEntry diffEntry) throws IOException {
 
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 9b8409d..c6f7acf 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -18,7 +18,6 @@
 
 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.FixReplacement;
@@ -69,8 +68,7 @@
     intralineDiffCalculator = calculator;
   }
 
-  PatchScript toPatchScript(
-      Repository git, PatchList list, PatchListEntry content, CommentDetail comments)
+  PatchScript toPatchScript(Repository git, PatchList list, PatchListEntry content)
       throws IOException {
 
     PatchFileChange change =
@@ -86,7 +84,7 @@
     ResolvedSides sides =
         resolveSides(
             git, sidesResolver, oldName(change), newName(change), list.getOldId(), list.getNewId());
-    return build(sides.a, sides.b, change, comments);
+    return build(sides.a, sides.b, change);
   }
 
   private ResolvedSides resolveSides(
@@ -136,7 +134,7 @@
             ChangeType.MODIFIED,
             PatchType.UNIFIED);
 
-    return build(a, b, change, null);
+    return build(a, b, change);
   }
 
   private PatchSide resolveSideA(
@@ -147,9 +145,7 @@
     }
   }
 
-  private PatchScript build(
-      PatchSide a, PatchSide b, PatchFileChange content, CommentDetail comments) {
-
+  private PatchScript build(PatchSide a, PatchSide b, PatchFileChange content) {
     ImmutableList<Edit> contentEdits = content.getEdits();
     ImmutableSet<Edit> editsDueToRebase = content.getEditsDueToRebase();
 
@@ -163,8 +159,7 @@
     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);
+        calculator.calculateDiffContent(new TextSource(a.src), new TextSource(b.src), finalEdits);
 
     return new PatchScript(
         content.getChangeType(),
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 29a89d6..02f46df 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -20,19 +20,13 @@
 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;
 import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
-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;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
@@ -84,7 +78,6 @@
   private final PatchSetUtil psUtil;
   private final Provider<PatchScriptBuilder> builderFactory;
   private final PatchListCache patchListCache;
-  private final CommentsUtil commentsUtil;
 
   private final String fileName;
   @Nullable private final PatchSet.Id psa;
@@ -92,12 +85,10 @@
   private final PatchSet.Id psb;
   private final DiffPreferencesInfo diffPrefs;
   private final ChangeEditUtil editReader;
-  private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
 
   private final Change.Id changeId;
-  private boolean loadComments = true;
 
   private ChangeNotes notes;
 
@@ -107,9 +98,7 @@
       PatchSetUtil psUtil,
       Provider<PatchScriptBuilder> builderFactory,
       PatchListCache patchListCache,
-      CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
-      Provider<CurrentUser> userProvider,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       @Assisted ChangeNotes notes,
@@ -122,9 +111,7 @@
     this.builderFactory = builderFactory;
     this.patchListCache = patchListCache;
     this.notes = notes;
-    this.commentsUtil = commentsUtil;
     this.editReader = editReader;
-    this.userProvider = userProvider;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
 
@@ -143,9 +130,7 @@
       PatchSetUtil psUtil,
       Provider<PatchScriptBuilder> builderFactory,
       PatchListCache patchListCache,
-      CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
-      Provider<CurrentUser> userProvider,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       @Assisted ChangeNotes notes,
@@ -158,9 +143,7 @@
     this.builderFactory = builderFactory;
     this.patchListCache = patchListCache;
     this.notes = notes;
-    this.commentsUtil = commentsUtil;
     this.editReader = editReader;
-    this.userProvider = userProvider;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
 
@@ -174,10 +157,6 @@
     checkArgument(parentNum >= 0, "parentNum must be >= 0");
   }
 
-  public void setLoadComments(boolean load) {
-    loadComments = load;
-  }
-
   @Override
   public PatchScript call()
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
@@ -203,7 +182,6 @@
 
         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);
@@ -211,16 +189,13 @@
             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);
 
-        Optional<CommentDetail> comments = loadComments(content, changeEdit);
-
-        return b.toPatchScript(git, list, content, comments.orElse(null));
+        return b.toPatchScript(git, list, content);
       } catch (PatchListNotAvailableException e) {
         throw new NoSuchChangeException(changeId, e);
       } catch (IOException e) {
@@ -238,14 +213,6 @@
     }
   }
 
-  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();
@@ -300,99 +267,6 @@
     }
   }
 
-  private static class CommentsLoader {
-    private final PatchSet.Id psa;
-    private final PatchSet.Id psb;
-    private final Provider<CurrentUser> userProvider;
-    private final ChangeNotes notes;
-    private final CommentsUtil commentsUtil;
-    private CommentDetail comments;
-
-    CommentsLoader(
-        PatchSet.Id psa,
-        PatchSet.Id psb,
-        Provider<CurrentUser> userProvider,
-        ChangeNotes notes,
-        CommentsUtil commentsUtil) {
-      this.psa = psa;
-      this.psb = psb;
-      this.userProvider = userProvider;
-      this.notes = notes;
-      this.commentsUtil = commentsUtil;
-    }
-
-    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(newName);
-          break;
-
-        case DELETED:
-          loadPublished(newName);
-          break;
-
-        case COPIED:
-        case RENAMED:
-          if (psa != null) {
-            loadPublished(oldName);
-          }
-          loadPublished(newName);
-          break;
-
-        case REWRITE:
-          break;
-      }
-
-      CurrentUser user = userProvider.get();
-      if (user.isIdentifiedUser()) {
-        Account.Id me = user.getAccountId();
-        switch (changeType) {
-          case ADDED:
-          case MODIFIED:
-            loadDrafts(me, newName);
-            break;
-
-          case DELETED:
-            loadDrafts(me, newName);
-            break;
-
-          case COPIED:
-          case RENAMED:
-            if (psa != null) {
-              loadDrafts(me, oldName);
-            }
-            loadDrafts(me, newName);
-            break;
-
-          case REWRITE:
-            break;
-        }
-      }
-      return Optional.of(comments);
-    }
-
-    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 static class IntraLineDiffCalculator
       implements PatchScriptBuilder.IntraLineDiffCalculator {
 
diff --git a/java/com/google/gerrit/server/patch/Text.java b/java/com/google/gerrit/server/patch/Text.java
index cc0a5e4..0f69965 100644
--- a/java/com/google/gerrit/server/patch/Text.java
+++ b/java/com/google/gerrit/server/patch/Text.java
@@ -18,21 +18,16 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.git.ObjectIds;
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.nio.charset.IllegalCharsetNameException;
 import java.nio.charset.UnsupportedCharsetException;
-import java.text.SimpleDateFormat;
 import org.eclipse.jgit.diff.RawText;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectLoader;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.storage.pack.PackConfig;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.mozilla.universalchardet.UniversalDetector;
@@ -46,101 +41,14 @@
   public static final Text EMPTY = new Text(NO_BYTES);
 
   public static Text forCommit(ObjectReader reader, AnyObjectId commitId) throws IOException {
-    try (RevWalk rw = new RevWalk(reader)) {
-      RevCommit c;
-      if (commitId instanceof RevCommit) {
-        c = (RevCommit) commitId;
-      } else {
-        c = rw.parseCommit(commitId);
-      }
-
-      StringBuilder b = new StringBuilder();
-      switch (c.getParentCount()) {
-        case 0:
-          break;
-        case 1:
-          {
-            RevCommit p = c.getParent(0);
-            rw.parseBody(p);
-            b.append("Parent:     ");
-            b.append(abbreviateName(p, reader));
-            b.append(" (");
-            b.append(p.getShortMessage());
-            b.append(")\n");
-            break;
-          }
-        default:
-          for (int i = 0; i < c.getParentCount(); i++) {
-            RevCommit p = c.getParent(i);
-            rw.parseBody(p);
-            b.append(i == 0 ? "Merge Of:   " : "            ");
-            b.append(abbreviateName(p, reader));
-            b.append(" (");
-            b.append(p.getShortMessage());
-            b.append(")\n");
-          }
-      }
-      appendPersonIdent(b, "Author", c.getAuthorIdent());
-      appendPersonIdent(b, "Commit", c.getCommitterIdent());
-      b.append("\n");
-      b.append(c.getFullMessage());
-      return new Text(b.toString().getBytes(UTF_8));
-    }
+    MagicFile commitMessageFile = MagicFile.forCommitMessage(reader, commitId);
+    return new Text(commitMessageFile.getFileContent().getBytes(UTF_8));
   }
 
   public static Text forMergeList(
       ComparisonType comparisonType, ObjectReader reader, AnyObjectId commitId) throws IOException {
-    try (RevWalk rw = new RevWalk(reader)) {
-      RevCommit c = rw.parseCommit(commitId);
-      StringBuilder b = new StringBuilder();
-      switch (c.getParentCount()) {
-        case 0:
-          break;
-        case 1:
-          {
-            break;
-          }
-        default:
-          int uniterestingParent =
-              comparisonType.isAgainstParent() ? comparisonType.getParentNum() : 1;
-
-          b.append("Merge List:\n\n");
-          for (RevCommit commit : MergeListBuilder.build(rw, c, uniterestingParent)) {
-            b.append("* ");
-            b.append(abbreviateName(commit, reader));
-            b.append(" ");
-            b.append(commit.getShortMessage());
-            b.append("\n");
-          }
-      }
-      return new Text(b.toString().getBytes(UTF_8));
-    }
-  }
-
-  private static String abbreviateName(RevCommit p, ObjectReader reader) throws IOException {
-    return ObjectIds.abbreviateName(p, 8, reader);
-  }
-
-  private static void appendPersonIdent(StringBuilder b, String field, PersonIdent person) {
-    if (person != null) {
-      b.append(field).append(":    ");
-      if (person.getName() != null) {
-        b.append(" ");
-        b.append(person.getName());
-      }
-      if (person.getEmailAddress() != null) {
-        b.append(" <");
-        b.append(person.getEmailAddress());
-        b.append(">");
-      }
-      b.append("\n");
-
-      SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss ZZZ");
-      sdf.setTimeZone(person.getTimeZone());
-      b.append(field).append("Date: ");
-      b.append(sdf.format(person.getWhen()));
-      b.append("\n");
-    }
+    MagicFile mergeListFile = MagicFile.forMergeList(comparisonType, reader, commitId);
+    return new Text(mergeListFile.getFileContent().getBytes(UTF_8));
   }
 
   public static byte[] asByteArray(ObjectLoader ldr)
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 2c1894e..37c773a 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -19,21 +19,16 @@
 
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.Map;
@@ -41,43 +36,15 @@
 
 /** Access control management for a user accessing a single change. */
 class ChangeControl {
-  @Singleton
-  static class Factory {
-    private final ChangeData.Factory changeDataFactory;
-    private final ChangeNotes.Factory notesFactory;
-
-    @Inject
-    Factory(ChangeData.Factory changeDataFactory, ChangeNotes.Factory notesFactory) {
-      this.changeDataFactory = changeDataFactory;
-      this.notesFactory = notesFactory;
-    }
-
-    ChangeControl create(RefControl refControl, Project.NameKey project, Change.Id changeId) {
-      return create(refControl, notesFactory.create(project, changeId));
-    }
-
-    ChangeControl create(RefControl refControl, ChangeNotes notes) {
-      return new ChangeControl(changeDataFactory, refControl, notes);
-    }
-  }
-
-  private final ChangeData.Factory changeDataFactory;
   private final RefControl refControl;
-  private final ChangeNotes notes;
+  private final ChangeData changeData;
 
-  private ChangeData cd;
-
-  private ChangeControl(
-      ChangeData.Factory changeDataFactory, RefControl refControl, ChangeNotes notes) {
-    this.changeDataFactory = changeDataFactory;
+  ChangeControl(RefControl refControl, ChangeData changeData) {
     this.refControl = refControl;
-    this.notes = notes;
+    this.changeData = changeData;
   }
 
-  ForChange asForChange(@Nullable ChangeData cd) {
-    if (cd != null) {
-      this.cd = cd;
-    }
+  ForChange asForChange() {
     return new ForChangeImpl();
   }
 
@@ -90,19 +57,12 @@
   }
 
   private Change getChange() {
-    return notes.getChange();
-  }
-
-  private ChangeData changeData() {
-    if (cd == null) {
-      cd = changeDataFactory.create(notes);
-    }
-    return cd;
+    return changeData.change();
   }
 
   /** Can this user see this change? */
   boolean isVisible() {
-    if (getChange().isPrivate() && !isPrivateVisible(changeData())) {
+    if (getChange().isPrivate() && !isPrivateVisible(changeData)) {
       return false;
     }
     // Does the user have READ permission on the destination?
@@ -237,7 +197,6 @@
   }
 
   private class ForChangeImpl extends ForChange {
-
     private Map<String, PermissionRange> labels;
     private String resourcePath;
 
@@ -249,7 +208,7 @@
         resourcePath =
             String.format(
                 "/projects/%s/+changes/%s",
-                getProjectControl().getProjectState().getName(), changeData().getId().get());
+                getProjectControl().getProjectState().getName(), changeData.getId().get());
       }
       return resourcePath;
     }
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index cb0d48a..cf6a184 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -21,10 +21,10 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index f3a3c78..3f84dff 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -31,7 +31,6 @@
       // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
       factory(ProjectControl.Factory.class);
       factory(DefaultRefFilter.Factory.class);
-      bind(ChangeControl.Factory.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 8479f02..dcaf485 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.api.access.PluginProjectPermission;
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index c7b1060..03d3b63 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -142,7 +142,7 @@
         return ImmutableList.of();
       }
       if (RefNames.isRefsChanges(refName)) {
-        boolean isChangeRefVisisble = canSeeSingleChangeRef(refName);
+        boolean isChangeRefVisisble = canSeeSingleChangeRef(repo, refName);
         if (isChangeRefVisisble) {
           logger.atFinest().log("Change ref %s is visible", refName);
           return refs;
@@ -366,12 +366,11 @@
     try {
       Map<Change.Id, BranchNameKey> visibleChanges = new HashMap<>();
       for (ChangeData cd : changeCache.getChangeData(project)) {
-        ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
         if (!projectState.statePermitsRead()) {
           continue;
         }
         try {
-          permissionBackendForProject.indexedChange(cd, notes).check(ChangePermission.READ);
+          permissionBackendForProject.change(cd).check(ChangePermission.READ);
           visibleChanges.put(cd.getId(), cd.change().getDest());
         } catch (AuthException e) {
           // Do nothing.
@@ -467,7 +466,8 @@
    * with refs-in-wants is used as that enables Gerrit to skip traditional advertisement of all
    * visible refs.
    */
-  private boolean canSeeSingleChangeRef(String refName) throws PermissionBackendException {
+  private boolean canSeeSingleChangeRef(Repository repo, String refName)
+      throws PermissionBackendException {
     // We are treating just a single change ref. We are therefore not going through regular ref
     // filtering, but use NoteDb directly. This makes it so that we can always serve this ref
     // even if the change is not part of the set of most recent changes that
@@ -481,7 +481,7 @@
     }
     ChangeNotes notes;
     try {
-      notes = changeNotesFactory.create(projectState.getNameKey(), cId);
+      notes = changeNotesFactory.create(repo, projectState.getNameKey(), cId);
     } catch (StorageException e) {
       throw new PermissionBackendException("can't construct change notes", e);
     }
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 2344781..749ca6b 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -173,11 +173,6 @@
     }
 
     @Override
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return new FailedChange(message, cause);
-    }
-
-    @Override
     public void check(RefPermission perm) throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index 7cce9c4..268570c 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -18,8 +18,8 @@
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
 import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.server.util.LabelVote;
 
 /** Permission representing a label. */
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 653c3b5f..eceb970 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -21,9 +21,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
@@ -173,15 +173,6 @@
       return ref(notes.getChange().getDest()).change(notes);
     }
 
-    /**
-     * Returns an instance scoped for the change loaded from index, and its destination ref and
-     * project. This method should only be used when database access is harmful and potentially
-     * stale data from the index is acceptable.
-     */
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return ref(notes.getChange().getDest()).indexedChange(cd, notes);
-    }
-
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(GlobalOrPluginPermission perm)
         throws AuthException, PermissionBackendException;
@@ -289,15 +280,6 @@
       return ref(notes.getChange().getDest().branch()).change(notes);
     }
 
-    /**
-     * Returns an instance scoped for the change loaded from index, and its destination ref and
-     * project. This method should only be used when database access is harmful and potentially
-     * stale data from the index is acceptable.
-     */
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return ref(notes.getChange().getDest().branch()).indexedChange(cd, notes);
-    }
-
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(CoreOrPluginProjectPermission perm)
         throws AuthException, PermissionBackendException;
@@ -386,12 +368,6 @@
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeNotes notes);
 
-    /**
-     * @return instance scoped to change loaded from index. This method should only be used when
-     *     database access is harmful and potentially stale data from the index is acceptable.
-     */
-    public abstract ForChange indexedChange(ChangeData cd, ChangeNotes notes);
-
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
 
diff --git a/java/com/google/gerrit/server/permissions/PermissionCollection.java b/java/com/google/gerrit/server/permissions/PermissionCollection.java
index 1f0370b..ddba52b 100644
--- a/java/com/google/gerrit/server/permissions/PermissionCollection.java
+++ b/java/com/google/gerrit/server/permissions/PermissionCollection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.gerrit.common.data.PermissionRule.Action.BLOCK;
+import static com.google.gerrit.entities.PermissionRule.Action.BLOCK;
 import static com.google.gerrit.server.project.RefPattern.containsParameters;
 import static com.google.gerrit.server.project.RefPattern.isRE;
 import static java.util.stream.Collectors.mapping;
@@ -23,11 +23,11 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index edffcc6..a92fde0 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -15,18 +15,18 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.common.data.AccessSection.ALL;
-import static com.google.gerrit.common.data.AccessSection.REGEX_PREFIX;
+import static com.google.gerrit.entities.AccessSection.ALL;
+import static com.google.gerrit.entities.AccessSection.REGEX_PREFIX;
 import static com.google.gerrit.entities.RefNames.REFS_TAGS;
 import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -74,9 +74,9 @@
   private final GitRepositoryManager repositoryManager;
   private final CurrentUser user;
   private final ProjectState state;
-  private final ChangeControl.Factory changeControlFactory;
   private final PermissionCollection.Factory permissionFilter;
   private final DefaultRefFilter.Factory refFilterFactory;
+  private final ChangeData.Factory changeDataFactory;
   private final AllUsersName allUsersName;
 
   private List<SectionMatcher> allSections;
@@ -88,15 +88,14 @@
       @GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
       @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
       PermissionCollection.Factory permissionFilter,
-      ChangeControl.Factory changeControlFactory,
       PermissionBackend permissionBackend,
       RefVisibilityControl refVisibilityControl,
       GitRepositoryManager repositoryManager,
       DefaultRefFilter.Factory refFilterFactory,
+      ChangeData.Factory changeDataFactory,
       AllUsersName allUsersName,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
-    this.changeControlFactory = changeControlFactory;
     this.uploadGroups = uploadGroups;
     this.receiveGroups = receiveGroups;
     this.permissionFilter = permissionFilter;
@@ -104,6 +103,7 @@
     this.refVisibilityControl = refVisibilityControl;
     this.repositoryManager = repositoryManager;
     this.refFilterFactory = refFilterFactory;
+    this.changeDataFactory = changeDataFactory;
     this.allUsersName = allUsersName;
     user = who;
     state = ps;
@@ -113,13 +113,8 @@
     return new ForProjectImpl();
   }
 
-  ChangeControl controlFor(Change change) {
-    return changeControlFactory.create(
-        controlForRef(change.getDest()), change.getProject(), change.getId());
-  }
-
-  ChangeControl controlFor(ChangeNotes notes) {
-    return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
+  ChangeControl controlFor(ChangeData cd) {
+    return new ChangeControl(controlForRef(cd.change().getDest()), cd);
   }
 
   RefControl controlForRef(BranchNameKey ref) {
@@ -133,7 +128,9 @@
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
       PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl = new RefControl(refVisibilityControl, this, repositoryManager, refName, relevant);
+      ctl =
+          new RefControl(
+              changeDataFactory, refVisibilityControl, this, repositoryManager, refName, relevant);
       refControls.put(refName, ctl);
     }
     return ctl;
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 69a7797..fd82559 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -18,11 +18,11 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -50,6 +50,7 @@
 class RefControl {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final ChangeData.Factory changeDataFactory;
   private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
   private final GitRepositoryManager repositoryManager;
@@ -68,11 +69,13 @@
   private Boolean hasReadPermissionOnRef;
 
   RefControl(
+      ChangeData.Factory changeDataFactory,
       RefVisibilityControl refVisibilityControl,
       ProjectControl projectControl,
       GitRepositoryManager repositoryManager,
       String ref,
       PermissionCollection relevant) {
+    this.changeDataFactory = changeDataFactory;
     this.refVisibilityControl = refVisibilityControl;
     this.projectControl = projectControl;
     this.repositoryManager = repositoryManager;
@@ -475,7 +478,7 @@
     @Override
     public ForChange change(ChangeData cd) {
       try {
-        return getProjectControl().controlFor(cd.notes()).asForChange(cd);
+        return getProjectControl().controlFor(cd).asForChange();
       } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
@@ -490,12 +493,9 @@
           "expected change in project %s, not %s",
           project,
           change.getProject());
-      return getProjectControl().controlFor(notes).asForChange(null);
-    }
-
-    @Override
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return getProjectControl().controlFor(notes).asForChange(cd);
+      // Having ChangeNotes means it's OK to load values from NoteDb if needed.
+      // ChangeData.Factory will allow lazyLoading
+      return getProjectControl().controlFor(changeDataFactory.create(notes)).asForChange();
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index c8a1ebe..4744037 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()).isVisible();
+      return projectControl.controlFor(cd).isVisible();
     }
 
     // Account visibility
@@ -153,7 +153,7 @@
       throw new IllegalStateException("unable to parse change id from edit ref " + refName);
     }
 
-    if (!projectControl.controlFor(cd.notes()).isVisible()) {
+    if (!projectControl.controlFor(cd).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/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index 814a8d2..6081e9a 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -18,7 +18,7 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.util.MostSpecificComparator;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/project/AccessControlModule.java b/java/com/google/gerrit/server/project/AccessControlModule.java
index 89ab8ee..ecad4e1 100644
--- a/java/com/google/gerrit/server/project/AccessControlModule.java
+++ b/java/com/google/gerrit/server/project/AccessControlModule.java
@@ -17,8 +17,8 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.config.AdministrateServerGroups;
 import com.google.gerrit.server.config.AdministrateServerGroupsProvider;
diff --git a/java/com/google/gerrit/server/project/AccountsSection.java b/java/com/google/gerrit/server/project/AccountsSection.java
deleted file mode 100644
index 30bd244..0000000
--- a/java/com/google/gerrit/server/project/AccountsSection.java
+++ /dev/null
@@ -1,35 +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.project;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.PermissionRule;
-import java.util.ArrayList;
-import java.util.List;
-
-public class AccountsSection {
-  protected List<PermissionRule> sameGroupVisibility;
-
-  public ImmutableList<PermissionRule> getSameGroupVisibility() {
-    if (sameGroupVisibility == null) {
-      sameGroupVisibility = ImmutableList.of();
-    }
-    return ImmutableList.copyOf(sameGroupVisibility);
-  }
-
-  public void setSameGroupVisibility(List<PermissionRule> sameGroupVisibility) {
-    this.sameGroupVisibility = new ArrayList<>(sameGroupVisibility);
-  }
-}
diff --git a/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java b/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
deleted file mode 100644
index 35de963..0000000
--- a/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// 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.project;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-
-/** Info about a single commentlink section in a config. */
-public class CommentLinkInfoImpl extends CommentLinkInfo {
-  public static class Enabled extends CommentLinkInfoImpl {
-    public Enabled(String name) {
-      super(name, true);
-    }
-
-    @Override
-    boolean isOverrideOnly() {
-      return true;
-    }
-  }
-
-  public static class Disabled extends CommentLinkInfoImpl {
-    public Disabled(String name) {
-      super(name, false);
-    }
-
-    @Override
-    boolean isOverrideOnly() {
-      return true;
-    }
-  }
-
-  public CommentLinkInfoImpl(String name, String match, String link, String html, Boolean enabled) {
-    checkArgument(name != null, "invalid commentlink.name");
-    checkArgument(!Strings.isNullOrEmpty(match), "invalid commentlink.%s.match", name);
-    link = Strings.emptyToNull(link);
-    html = Strings.emptyToNull(html);
-    checkArgument(
-        (link != null && html == null) || (link == null && html != null),
-        "commentlink.%s must have either link or html",
-        name);
-    this.name = name;
-    this.match = match;
-    this.link = link;
-    this.html = html;
-    this.enabled = enabled;
-  }
-
-  private CommentLinkInfoImpl(CommentLinkInfo src, boolean enabled) {
-    this.name = src.name;
-    this.match = src.match;
-    this.link = src.link;
-    this.html = src.html;
-    this.enabled = enabled;
-  }
-
-  private CommentLinkInfoImpl(String name, boolean enabled) {
-    this.name = name;
-    this.match = null;
-    this.link = null;
-    this.html = null;
-    this.enabled = enabled;
-  }
-
-  boolean isOverrideOnly() {
-    return false;
-  }
-
-  CommentLinkInfo inherit(CommentLinkInfo src) {
-    return new CommentLinkInfoImpl(src, enabled);
-  }
-}
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index 4987d00..1b9dc37 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.StoredCommentLinkInfo;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.server.config.ConfigUpdatedEvent;
 import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
@@ -47,12 +48,12 @@
     List<CommentLinkInfo> cls = Lists.newArrayListWithCapacity(subsections.size());
     for (String name : subsections) {
       try {
-        CommentLinkInfoImpl cl = ProjectConfig.buildCommentLink(cfg, name, true);
-        if (cl.isOverrideOnly()) {
+        StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name, true);
+        if (cl.getOverrideOnly()) {
           logger.atWarning().log("commentlink %s empty except for \"enabled\"", name);
           continue;
         }
-        cls.add(cl);
+        cls.add(cl.toInfo());
       } catch (IllegalArgumentException e) {
         logger.atWarning().log("invalid commentlink: %s", e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/project/ConfiguredMimeTypes.java b/java/com/google/gerrit/server/project/ConfiguredMimeTypes.java
deleted file mode 100644
index a6661f7..0000000
--- a/java/com/google/gerrit/server/project/ConfiguredMimeTypes.java
+++ /dev/null
@@ -1,113 +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.project;
-
-import com.google.common.flogger.FluentLogger;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import java.util.regex.Pattern;
-import java.util.regex.PatternSyntaxException;
-import org.eclipse.jgit.errors.InvalidPatternException;
-import org.eclipse.jgit.fnmatch.FileNameMatcher;
-import org.eclipse.jgit.lib.Config;
-
-public class ConfiguredMimeTypes {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final String MIMETYPE = "mimetype";
-  private static final String KEY_PATH = "path";
-
-  private final List<TypeMatcher> matchers;
-
-  ConfiguredMimeTypes(String projectName, Config rc) {
-    Set<String> types = rc.getSubsections(MIMETYPE);
-    if (types.isEmpty()) {
-      matchers = Collections.emptyList();
-    } else {
-      matchers = new ArrayList<>();
-      for (String typeName : types) {
-        for (String path : rc.getStringList(MIMETYPE, typeName, KEY_PATH)) {
-          try {
-            add(typeName, path);
-          } catch (PatternSyntaxException | InvalidPatternException e) {
-            logger.atWarning().log(
-                "Ignoring invalid %s.%s.%s = %s in project %s: %s",
-                MIMETYPE, typeName, KEY_PATH, path, projectName, e.getMessage());
-          }
-        }
-      }
-    }
-  }
-
-  private void add(String typeName, String path)
-      throws PatternSyntaxException, InvalidPatternException {
-    if (path.startsWith("^")) {
-      matchers.add(new ReType(typeName, path));
-    } else {
-      matchers.add(new FnType(typeName, path));
-    }
-  }
-
-  public String getMimeType(String path) {
-    for (TypeMatcher m : matchers) {
-      if (m.matches(path)) {
-        return m.type;
-      }
-    }
-    return null;
-  }
-
-  private abstract static class TypeMatcher {
-    final String type;
-
-    TypeMatcher(String type) {
-      this.type = type;
-    }
-
-    abstract boolean matches(String path);
-  }
-
-  private static class FnType extends TypeMatcher {
-    private final FileNameMatcher matcher;
-
-    FnType(String type, String pattern) throws InvalidPatternException {
-      super(type);
-      this.matcher = new FileNameMatcher(pattern, null);
-    }
-
-    @Override
-    boolean matches(String input) {
-      FileNameMatcher m = new FileNameMatcher(matcher);
-      m.append(input);
-      return m.isMatch();
-    }
-  }
-
-  private static class ReType extends TypeMatcher {
-    private final Pattern re;
-
-    ReType(String type, String pattern) throws PatternSyntaxException {
-      super(type);
-      this.re = Pattern.compile(pattern);
-    }
-
-    @Override
-    boolean matches(String input) {
-      return re.matcher(input).matches();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index c2eb79d..f054e84 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -17,12 +17,12 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -90,7 +90,7 @@
 
     IdentifiedUser iUser = user.asIdentifiedUser();
     Collection<ContributorAgreement> contributorAgreements =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
+        projectCache.getAllProjects().getConfig().getContributorAgreements().values();
     List<UUID> okGroupIds = new ArrayList<>();
     for (ContributorAgreement ca : contributorAgreements) {
       List<AccountGroup.UUID> groupIds;
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index ba7dc95..98dc44a 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.TabFile;
@@ -56,7 +57,7 @@
       }
       AccountGroup.UUID uuid = AccountGroup.uuid(row.left);
       String name = row.right;
-      GroupReference ref = new GroupReference(uuid, name);
+      GroupReference ref = GroupReference.create(uuid, name);
 
       groupsByUUID.put(uuid, ref);
     }
@@ -64,10 +65,30 @@
     return new GroupList(groupsByUUID);
   }
 
+  @Nullable
   public GroupReference byUUID(AccountGroup.UUID uuid) {
     return byUUID.get(uuid);
   }
 
+  public Map<AccountGroup.UUID, GroupReference> byUUID() {
+    return byUUID;
+  }
+
+  @Nullable
+  public GroupReference byName(String name) {
+    return byUUID.entrySet().stream()
+        .map(Map.Entry::getValue)
+        .filter(groupReference -> groupReference.getName().equals(name))
+        .findAny()
+        .orElse(null);
+  }
+
+  /**
+   * Returns the {@link GroupReference} instance that {@link GroupList} holds on to that has the
+   * same {@link com.google.gerrit.entities.AccountGroup.UUID} as the argument. Will store the
+   * argument internally, if no group with this {@link com.google.gerrit.entities.AccountGroup.UUID}
+   * was stored previously.
+   */
   public GroupReference resolve(GroupReference group) {
     if (group != null) {
       if (group.getUUID() == null || group.getUUID().get() == null) {
@@ -86,6 +107,10 @@
     return group;
   }
 
+  public void renameGroup(AccountGroup.UUID uuid, String name) {
+    byUUID.replace(uuid, GroupReference.create(uuid, name));
+  }
+
   public Collection<GroupReference> references() {
     return byUUID.values();
   }
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 0452d0b..7aa4029 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -16,8 +16,8 @@
 
 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.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 
@@ -31,7 +31,7 @@
         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.canOverride = toBoolean(labelType.isCanOverride());
     label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
     label.copyMinScore = toBoolean(labelType.isCopyMinScore());
     label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
@@ -41,8 +41,8 @@
     label.copyAllScoresOnMergeFirstParentUpdate =
         toBoolean(labelType.isCopyAllScoresOnMergeFirstParentUpdate());
     label.copyValues = labelType.getCopyValues().isEmpty() ? null : labelType.getCopyValues();
-    label.allowPostSubmit = toBoolean(labelType.allowPostSubmit());
-    label.ignoreSelfApproval = toBoolean(labelType.ignoreSelfApproval());
+    label.allowPostSubmit = toBoolean(labelType.isAllowPostSubmit());
+    label.ignoreSelfApproval = toBoolean(labelType.isIgnoreSelfApproval());
     return label;
   }
 
diff --git a/java/com/google/gerrit/server/project/LabelResource.java b/java/com/google/gerrit/server/project/LabelResource.java
index a7a2f07..2df9ff1 100644
--- a/java/com/google/gerrit/server/project/LabelResource.java
+++ b/java/com/google/gerrit/server/project/LabelResource.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index 6a2ae50..5124e0e 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -52,8 +52,8 @@
    * Get the cached data for a project by its unique name.
    *
    * @param projectName name of the project.
-   * @return an {@link Optional} wrapping the the cached data; {@code absent} if no such project
-   *     exists or the projectName is null
+   * @return an {@link Optional} wrapping the cached data; {@code absent} if no such project exists
+   *     or the projectName is null
    * @throws StorageException when there was an error.
    */
   Optional<ProjectState> get(@Nullable Project.NameKey projectName) throws StorageException;
diff --git a/java/com/google/gerrit/server/project/ProjectCacheClock.java b/java/com/google/gerrit/server/project/ProjectCacheClock.java
deleted file mode 100644
index eb451fd..0000000
--- a/java/com/google/gerrit/server/project/ProjectCacheClock.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// 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.project;
-
-import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
-import org.eclipse.jgit.lib.Config;
-
-/** Ticks periodically to force refresh events for {@link ProjectCacheImpl}. */
-@Singleton
-public class ProjectCacheClock implements LifecycleListener {
-  private final Config serverConfig;
-
-  private final AtomicLong generation = new AtomicLong();
-
-  private ScheduledExecutorService executor;
-
-  @Inject
-  public ProjectCacheClock(@GerritServerConfig Config serverConfig) {
-    this.serverConfig = serverConfig;
-  }
-
-  @Override
-  public void start() {
-    long checkFrequencyMillis = checkFrequency(serverConfig);
-
-    if (checkFrequencyMillis == Long.MAX_VALUE) {
-      // Start with generation 1 (to avoid magic 0 below).
-      // Do not begin background thread, disabling the clock.
-      generation.set(1);
-    } else if (10 < checkFrequencyMillis) {
-      // Start with generation 1 (to avoid magic 0 below).
-      generation.set(1);
-      executor =
-          new LoggingContextAwareScheduledExecutorService(
-              Executors.newScheduledThreadPool(
-                  1,
-                  new ThreadFactoryBuilder()
-                      .setNameFormat("ProjectCacheClock-%d")
-                      .setDaemon(true)
-                      .setPriority(Thread.MIN_PRIORITY)
-                      .build()));
-      @SuppressWarnings("unused") // Runnable already handles errors
-      Future<?> possiblyIgnoredError =
-          executor.scheduleAtFixedRate(
-              generation::incrementAndGet,
-              checkFrequencyMillis,
-              checkFrequencyMillis,
-              TimeUnit.MILLISECONDS);
-    } else {
-      // Magic generation 0 triggers ProjectState to always
-      // check on each needsRefresh() request we make to it.
-      generation.set(0);
-    }
-  }
-
-  @Override
-  public void stop() {
-    if (executor != null) {
-      executor.shutdown();
-    }
-  }
-
-  long read() {
-    return generation.get();
-  }
-
-  private static long checkFrequency(Config serverConfig) {
-    String freq = serverConfig.getString("cache", "projects", "checkFrequency");
-    if (freq != null && ("disabled".equalsIgnoreCase(freq) || "off".equalsIgnoreCase(freq))) {
-      return Long.MAX_VALUE;
-    }
-    return TimeUnit.MILLISECONDS.convert(
-        ConfigUtil.getTimeUnit(
-            serverConfig, "cache", "projects", "checkFrequency", 5, TimeUnit.MINUTES),
-        TimeUnit.MINUTES);
-  }
-}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 1523b1b..5ea95fb 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -24,19 +25,35 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.CachedProjectConfig;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.Counter2;
 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.Timer0;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.cache.CacheModule;
+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.cache.serialize.ProtobufSerializer;
+import com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSerializer;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
@@ -47,15 +64,22 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
+import com.google.protobuf.ByteString;
 import java.io.IOException;
+import java.time.Duration;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
 
 /** Cache of project information, including access rights. */
 @Singleton
@@ -64,13 +88,45 @@
 
   public static final String CACHE_NAME = "projects";
 
+  public static final String PERSISTED_CACHE_NAME = "persisted_projects";
+
   private static final String CACHE_LIST = "project_list";
 
   public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(CACHE_NAME, String.class, ProjectState.class).loader(Loader.class);
+        // We split the project cache into two parts for performance reasons:
+        // 1) An in-memory part that has only the project name as key.
+        // 2) A persisted part that has the name and revision as key.
+        //
+        // When loading dashboards or returning change query results we potentially
+        // need to access hundreds of projects because each change could originate in
+        // a different project and, through inheritance, require us to check even more
+        // projects when evaluating permissions. It's not feasible to read the revision
+        // of refs/meta/config from each of these repos as that would require opening
+        // them all and reading their ref list or table.
+        // At the same time, we want the persisted cache to be immutable and we want it
+        // to be impossible that a value for a given key is out of date. We therefore
+        // require a revision in the key. That is in line with the rest of the caches in
+        // Gerrit.
+        //
+        // Splitting the cache into two chunks internally in this class allows us to retain
+        // the existing performance guarantees of not requiring reads for the repo for values
+        // cached in-memory but also to persist the cache which leads to a much improved
+        // cold-start behavior and in-memory miss latency.
+        cache(CACHE_NAME, Project.NameKey.class, CachedProjectConfig.class)
+            .loader(InMemoryLoader.class)
+            .refreshAfterWrite(Duration.ofMinutes(15))
+            .expireAfterWrite(Duration.ofHours(1));
+
+        persist(PERSISTED_CACHE_NAME, Cache.ProjectCacheKeyProto.class, CachedProjectConfig.class)
+            .loader(PersistedLoader.class)
+            .keySerializer(new ProtobufSerializer<>(Cache.ProjectCacheKeyProto.parser()))
+            .valueSerializer(PersistedProjectConfigSerializer.INSTANCE)
+            .diskLimit(1 << 30) // 1 GiB
+            .version(2)
+            .maximumWeight(0);
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
             .maximumWeight(1)
@@ -84,7 +140,6 @@
               @Override
               protected void configure() {
                 listener().to(ProjectCacheWarmer.class);
-                listener().to(ProjectCacheClock.class);
               }
             });
       }
@@ -93,29 +148,29 @@
 
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
-  private final LoadingCache<String, ProjectState> byName;
+  private final LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache;
   private final LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list;
   private final Lock listLock;
-  private final ProjectCacheClock clock;
   private final Provider<ProjectIndexer> indexer;
   private final Timer0 guessRelevantGroupsLatency;
+  private final ProjectState.Factory projectStateFactory;
 
   @Inject
   ProjectCacheImpl(
       final AllProjectsName allProjectsName,
       final AllUsersName allUsersName,
-      @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
+      @Named(CACHE_NAME) LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache,
       @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
-      ProjectCacheClock clock,
       Provider<ProjectIndexer> indexer,
-      MetricMaker metricMaker) {
+      MetricMaker metricMaker,
+      ProjectState.Factory projectStateFactory) {
     this.allProjectsName = allProjectsName;
     this.allUsersName = allUsersName;
-    this.byName = byName;
+    this.inMemoryProjectCache = inMemoryProjectCache;
     this.list = list;
     this.listLock = new ReentrantLock(true /* fair */);
-    this.clock = clock;
     this.indexer = indexer;
+    this.projectStateFactory = projectStateFactory;
 
     this.guessRelevantGroupsLatency =
         metricMaker.newTimer(
@@ -142,13 +197,8 @@
     }
 
     try {
-      ProjectState state = byName.get(projectName.get());
-      if (state != null && state.needsRefresh(clock.read())) {
-        byName.invalidate(projectName.get());
-        state = byName.get(projectName.get());
-      }
-      return Optional.of(state);
-    } catch (Exception e) {
+      return Optional.of(inMemoryProjectCache.get(projectName)).map(projectStateFactory::create);
+    } catch (ExecutionException e) {
       if ((e.getCause() instanceof RepositoryNotFoundException)) {
         logger.atFine().log("Cannot find project %s", projectName.get());
         return Optional.empty();
@@ -162,7 +212,7 @@
   public void evict(Project.NameKey p) {
     if (p != null) {
       logger.atFine().log("Evict project '%s'", p.get());
-      byName.invalidate(p.get());
+      inMemoryProjectCache.invalidate(p);
     }
   }
 
@@ -227,9 +277,9 @@
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
       return all().stream()
-          .map(n -> byName.getIfPresent(n.get()))
+          .map(n -> inMemoryProjectCache.getIfPresent(n))
           .filter(Objects::nonNull)
-          .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
+          .flatMap(p -> p.getAllGroupUUIDs().stream())
           // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
           // against them just in case there is a bug or corner case.
           .filter(id -> id != null && id.get() != null)
@@ -250,43 +300,152 @@
     }
   }
 
-  static class Loader extends CacheLoader<String, ProjectState> {
-    private final ProjectState.Factory projectStateFactory;
-    private final GitRepositoryManager mgr;
-    private final ProjectCacheClock clock;
+  /**
+   * Returns a {@code MurMur128} hash of the contents of {@code etc/All-Projects-project.config}.
+   */
+  public static byte[] allProjectsFileProjectConfigHash(
+      AllProjectsName allProjectsName, SitePaths sitePaths) {
+    // Hash the contents of All-Projects-project.config
+    // This is a way for administrators to orchestrate project.config changes across many Gerrit
+    // instances.
+    // When this file changes, we need to make sure we disregard persistently cached project
+    // state.
+    FileBasedConfig fileBasedConfig =
+        new FileBasedConfig(
+            sitePaths
+                .etc_dir
+                .resolve(allProjectsName.get())
+                .resolve(ProjectConfig.PROJECT_CONFIG)
+                .toFile(),
+            FS.DETECTED);
+    try {
+      fileBasedConfig.load();
+    } catch (IOException | ConfigInvalidException e) {
+      throw new IllegalStateException(e);
+    }
+    return Hashing.murmur3_128().hashString(fileBasedConfig.toText(), UTF_8).asBytes();
+  }
+
+  @Singleton
+  static class InMemoryLoader extends CacheLoader<Project.NameKey, CachedProjectConfig> {
+    private final LoadingCache<Cache.ProjectCacheKeyProto, CachedProjectConfig> persistedCache;
+    private final GitRepositoryManager repoManager;
+    private final ListeningExecutorService cacheRefreshExecutor;
+    private final Counter2<String, Boolean> refreshCounter;
+    private final AllProjectsName allProjectsName;
+    private final SitePaths sitePaths;
+
+    @Inject
+    InMemoryLoader(
+        @Named(PERSISTED_CACHE_NAME)
+            LoadingCache<Cache.ProjectCacheKeyProto, CachedProjectConfig> persistedCache,
+        GitRepositoryManager repoManager,
+        @CacheRefreshExecutor ListeningExecutorService cacheRefreshExecutor,
+        MetricMaker metricMaker,
+        AllProjectsName allProjectsName,
+        SitePaths sitePaths) {
+      this.persistedCache = persistedCache;
+      this.repoManager = repoManager;
+      this.cacheRefreshExecutor = cacheRefreshExecutor;
+      refreshCounter =
+          metricMaker.newCounter(
+              "caches/refresh_count",
+              new Description("count").setRate(),
+              Field.ofString("cache", Metadata.Builder::className).build(),
+              Field.ofBoolean("outdated", Metadata.Builder::outdated).build());
+      this.allProjectsName = allProjectsName;
+      this.sitePaths = sitePaths;
+    }
+
+    @Override
+    public CachedProjectConfig load(Project.NameKey key) throws IOException, ExecutionException {
+      try (TraceTimer ignored =
+              TraceContext.newTimer(
+                  "Loading project from serialized cache",
+                  Metadata.builder().projectName(key.get()).build());
+          Repository git = repoManager.openRepository(key)) {
+        Cache.ProjectCacheKeyProto.Builder keyProto =
+            Cache.ProjectCacheKeyProto.newBuilder().setProject(key.get());
+        Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
+        if (key.get().equals(allProjectsName.get())) {
+          byte[] fileHash = allProjectsFileProjectConfigHash(allProjectsName, sitePaths);
+          keyProto.setGlobalConfigRevision(ByteString.copyFrom(fileHash));
+        }
+        if (configRef != null) {
+          keyProto.setRevision(ObjectIdConverter.create().toByteString(configRef.getObjectId()));
+        }
+        return persistedCache.get(keyProto.build());
+      }
+    }
+
+    @Override
+    public ListenableFuture<CachedProjectConfig> reload(
+        Project.NameKey key, CachedProjectConfig oldState) throws Exception {
+      try (TraceTimer ignored =
+          TraceContext.newTimer(
+              "Reload project", Metadata.builder().projectName(key.get()).build())) {
+        try (Repository git = repoManager.openRepository(key)) {
+          Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
+          if (configRef != null && configRef.getObjectId().equals(oldState.getRevision().get())) {
+            refreshCounter.increment(CACHE_NAME, false);
+            return Futures.immediateFuture(oldState);
+          }
+        }
+
+        // Repository is not thread safe, so we have to open it on the thread that does the loading.
+        // Just invoke the loader on the other thread.
+        refreshCounter.increment(CACHE_NAME, true);
+        return cacheRefreshExecutor.submit(() -> load(key));
+      }
+    }
+  }
+
+  @Singleton
+  static class PersistedLoader
+      extends CacheLoader<Cache.ProjectCacheKeyProto, CachedProjectConfig> {
+    private final GitRepositoryManager repoManager;
     private final ProjectConfig.Factory projectConfigFactory;
 
     @Inject
-    Loader(
-        ProjectState.Factory psf,
-        GitRepositoryManager g,
-        ProjectCacheClock clock,
-        ProjectConfig.Factory projectConfigFactory) {
-      projectStateFactory = psf;
-      mgr = g;
-      this.clock = clock;
+    PersistedLoader(GitRepositoryManager repoManager, ProjectConfig.Factory projectConfigFactory) {
+      this.repoManager = repoManager;
       this.projectConfigFactory = projectConfigFactory;
     }
 
     @Override
-    public ProjectState load(String projectName) throws Exception {
-      try (TraceTimer timer =
+    public CachedProjectConfig load(Cache.ProjectCacheKeyProto key) throws Exception {
+      Project.NameKey nameKey = Project.nameKey(key.getProject());
+      ObjectId revision =
+          key.getRevision().isEmpty()
+              ? null
+              : ObjectIdConverter.create().fromByteString(key.getRevision());
+      try (TraceTimer ignored =
           TraceContext.newTimer(
-              "Loading project", Metadata.builder().projectName(projectName).build())) {
-        long now = clock.read();
-        Project.NameKey key = Project.nameKey(projectName);
-        try (Repository git = mgr.openRepository(key)) {
-          ProjectConfig cfg = projectConfigFactory.create(key);
-          cfg.load(key, git);
-
-          ProjectState state = projectStateFactory.create(cfg);
-          state.initLastCheck(now);
-          return state;
+              "Loading project from repo", Metadata.builder().projectName(nameKey.get()).build())) {
+        try (Repository git = repoManager.openRepository(nameKey)) {
+          ProjectConfig cfg = projectConfigFactory.create(nameKey);
+          cfg.load(git, revision);
+          return cfg.getCacheable();
         }
       }
     }
   }
 
+  private enum PersistedProjectConfigSerializer implements CacheSerializer<CachedProjectConfig> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CachedProjectConfig value) {
+      return Protos.toByteArray(CachedProjectConfigSerializer.serialize(value));
+    }
+
+    @Override
+    public CachedProjectConfig deserialize(byte[] in) {
+      return CachedProjectConfigSerializer.deserialize(
+          Protos.parseUnchecked(Cache.CachedProjectConfigProto.parser(), in));
+    }
+  }
+
   static class ListKey {
     static final ListKey ALL = new ListKey();
 
@@ -311,11 +470,11 @@
 
   @VisibleForTesting
   public void evictAllByName() {
-    byName.invalidateAll();
+    inMemoryProjectCache.invalidateAll();
   }
 
   @VisibleForTesting
   public long sizeAllByName() {
-    return byName.size();
+    return inMemoryProjectCache.size();
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 4ab583d..89038e2 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.project;
 
 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.gerrit.common.data.Permission.isPermission;
+import static com.google.gerrit.entities.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;
@@ -27,40 +28,44 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ContributorAgreement;
 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.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
-import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.NotifyConfig;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.BranchOrderSection;
-import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -81,6 +86,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Consumer;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -91,10 +97,11 @@
 import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.util.FS;
 
 public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static final String COMMENTLINK = "commentlink";
   public static final String LABEL = "label";
   public static final String KEY_FUNCTION = "function";
@@ -241,18 +248,47 @@
   private Map<String, LabelType> labelSections;
   private ConfiguredMimeTypes mimeTypes;
   private Map<Project.NameKey, SubscribeSection> subscribeSections;
-  private Map<String, CommentLinkInfoImpl> commentLinkSections;
+  private Map<String, StoredCommentLinkInfo> commentLinkSections;
   private List<ValidationError> validationErrors;
   private ObjectId rulesId;
   private long maxObjectSizeLimit;
   private Map<String, Config> pluginConfigs;
+  private Map<String, Config> projectLevelConfigs;
   private boolean checkReceivedObjects;
   private Set<String> sectionsWithUnknownPermissions;
   private boolean hasLegacyPermissions;
   private Map<String, List<String>> extensionPanelSections;
-  private Map<String, GroupReference> groupsByName;
 
-  public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name, boolean allowRaw)
+  /** Returns an immutable, thread-safe representation of this object that can be cached. */
+  public CachedProjectConfig getCacheable() {
+    CachedProjectConfig.Builder builder =
+        CachedProjectConfig.builder()
+            .setProject(project)
+            .setAccountsSection(accountsSection)
+            .setBranchOrderSection(Optional.ofNullable(branchOrderSection))
+            .setMimeTypes(mimeTypes)
+            .setRulesId(Optional.ofNullable(rulesId))
+            .setRevision(Optional.ofNullable(getRevision()))
+            .setMaxObjectSizeLimit(maxObjectSizeLimit)
+            .setCheckReceivedObjects(checkReceivedObjects)
+            .setExtensionPanelSections(extensionPanelSections);
+    groupList.byUUID().values().forEach(g -> builder.addGroup(g));
+    accessSections.values().forEach(a -> builder.addAccessSection(a));
+    contributorAgreements.values().forEach(c -> builder.addContributorAgreement(c));
+    notifySections.values().forEach(n -> builder.addNotifySection(n));
+    subscribeSections.values().forEach(s -> builder.addSubscribeSection(s));
+    commentLinkSections.values().forEach(c -> builder.addCommentLinkSection(c));
+    labelSections.values().forEach(l -> builder.addLabelSection(l));
+    pluginConfigs
+        .entrySet()
+        .forEach(c -> builder.addPluginConfig(c.getKey(), c.getValue().toText()));
+    projectLevelConfigs
+        .entrySet()
+        .forEach(c -> builder.addProjectLevelConfig(c.getKey(), c.getValue().toText()));
+    return builder.build();
+  }
+
+  public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name, boolean allowRaw)
       throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
     if (match != null) {
@@ -282,15 +318,21 @@
         && !hasHtml
         && enabled != null) {
       if (enabled) {
-        return new CommentLinkInfoImpl.Enabled(name);
+        return StoredCommentLinkInfo.enabled(name);
       }
-      return new CommentLinkInfoImpl.Disabled(name);
+      return StoredCommentLinkInfo.disabled(name);
     }
-    return new CommentLinkInfoImpl(name, match, link, html, enabled);
+    return StoredCommentLinkInfo.builder(name)
+        .setMatch(match)
+        .setLink(link)
+        .setHtml(html)
+        .setEnabled(enabled)
+        .setOverrideOnly(false)
+        .build();
   }
 
-  public void addCommentLinkSection(CommentLinkInfoImpl commentLink) {
-    commentLinkSections.put(commentLink.name, commentLink);
+  public void addCommentLinkSection(StoredCommentLinkInfo commentLink) {
+    commentLinkSections.put(commentLink.getName(), commentLink);
   }
 
   public void removeCommentLinkSection(String name) {
@@ -325,29 +367,35 @@
     return project;
   }
 
+  public void setProject(Project.Builder project) {
+    this.project = project.build();
+  }
+
+  public void updateProject(Consumer<Project.Builder> update) {
+    Project.Builder builder = project.toBuilder();
+    update.accept(builder);
+    project = builder.build();
+  }
+
   public AccountsSection getAccountsSection() {
     return accountsSection;
   }
 
-  public Map<String, List<String>> getExtensionPanelSections() {
-    return extensionPanelSections;
+  public void setAccountsSection(AccountsSection accountsSection) {
+    this.accountsSection = accountsSection;
   }
 
   public AccessSection getAccessSection(String name) {
-    return getAccessSection(name, false);
+    return accessSections.get(name);
   }
 
-  public AccessSection getAccessSection(String name, boolean create) {
-    AccessSection as = accessSections.get(name);
-    if (as == null && create) {
-      as = new AccessSection(name);
-      accessSections.put(name, as);
-    }
-    return as;
-  }
-
-  public ImmutableSet<String> getAccessSectionNames() {
-    return ImmutableSet.copyOf(accessSections.keySet());
+  public void upsertAccessSection(String name, Consumer<AccessSection.Builder> update) {
+    AccessSection.Builder accessSectionBuilder =
+        accessSections.containsKey(name)
+            ? accessSections.get(name).toBuilder()
+            : AccessSection.builder(name);
+    update.accept(accessSectionBuilder);
+    accessSections.put(name, accessSectionBuilder.build());
   }
 
   public Collection<AccessSection> getAccessSections() {
@@ -358,30 +406,25 @@
     return branchOrderSection;
   }
 
+  public void setBranchOrderSection(BranchOrderSection branchOrderSection) {
+    this.branchOrderSection = branchOrderSection;
+  }
+
   public Map<Project.NameKey, SubscribeSection> getSubscribeSections() {
     return subscribeSections;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
-    Collection<SubscribeSection> ret = new ArrayList<>();
-    for (SubscribeSection s : subscribeSections.values()) {
-      if (s.appliesTo(branch)) {
-        ret.add(s);
-      }
-    }
-    return ret;
-  }
-
   public void addSubscribeSection(SubscribeSection s) {
-    subscribeSections.put(s.getProject(), s);
+    subscribeSections.put(s.project(), s);
   }
 
   public void remove(AccessSection section) {
     if (section != null) {
       String name = section.getName();
       if (sectionsWithUnknownPermissions.contains(name)) {
-        AccessSection a = accessSections.get(name);
-        a.setPermissions(new ArrayList<>());
+        AccessSection.Builder a = accessSections.get(name).toBuilder();
+        a.modifyPermissions(List::clear);
+        accessSections.put(name, a.build());
       } else {
         accessSections.remove(name);
       }
@@ -392,8 +435,9 @@
     if (permission == null) {
       remove(section);
     } else if (section != null) {
-      AccessSection a = accessSections.get(section.getName());
-      a.remove(permission);
+      AccessSection a =
+          accessSections.get(section.getName()).toBuilder().remove(permission.toBuilder()).build();
+      accessSections.put(section.getName(), a);
       if (a.getPermissions().isEmpty()) {
         remove(a);
       }
@@ -412,37 +456,23 @@
       if (p == null) {
         return;
       }
-      p.remove(rule);
-      if (p.getRules().isEmpty()) {
-        a.remove(permission);
+      AccessSection.Builder accessSectionBuilder = a.toBuilder();
+      Permission.Builder permissionBuilder =
+          accessSectionBuilder.upsertPermission(permission.getName());
+      permissionBuilder.remove(rule);
+      if (permissionBuilder.build().getRules().isEmpty()) {
+        accessSectionBuilder.remove(permissionBuilder);
       }
+      a = accessSectionBuilder.build();
+      accessSections.put(section.getName(), a);
       if (a.getPermissions().isEmpty()) {
         remove(a);
       }
     }
   }
 
-  public void replace(AccessSection section) {
-    for (Permission permission : section.getPermissions()) {
-      for (PermissionRule rule : permission.getRules()) {
-        rule.setGroup(resolve(rule.getGroup()));
-      }
-    }
-
-    accessSections.put(section.getName(), section);
-  }
-
   public ContributorAgreement getContributorAgreement(String name) {
-    return getContributorAgreement(name, false);
-  }
-
-  public ContributorAgreement getContributorAgreement(String name, boolean create) {
-    ContributorAgreement ca = contributorAgreements.get(name);
-    if (ca == null && create) {
-      ca = new ContributorAgreement(name);
-      contributorAgreements.put(name, ca);
-    }
-    return ca;
+    return contributorAgreements.get(name);
   }
 
   public Collection<ContributorAgreement> getContributorAgreements() {
@@ -456,12 +486,15 @@
   }
 
   public void replace(ContributorAgreement section) {
-    section.setAutoVerify(resolve(section.getAutoVerify()));
+    ContributorAgreement.Builder ca = section.toBuilder();
+    ca.setAutoVerify(resolve(section.getAutoVerify()));
+    ImmutableList.Builder<PermissionRule> newRules = ImmutableList.builder();
     for (PermissionRule rule : section.getAccepted()) {
-      rule.setGroup(resolve(rule.getGroup()));
+      newRules.add(rule.toBuilder().setGroup(resolve(rule.getGroup())).build());
     }
+    ca.setAccepted(newRules.build());
 
-    contributorAgreements.put(section.getName(), section);
+    contributorAgreements.put(section.getName(), ca.build());
   }
 
   public Collection<NotifyConfig> getNotifyConfigs() {
@@ -476,7 +509,27 @@
     return labelSections;
   }
 
-  public Collection<CommentLinkInfoImpl> getCommentLinkSections() {
+  /** Adds or replaces the given {@link LabelType} in this config. */
+  public void upsertLabelType(LabelType labelType) {
+    labelSections.put(labelType.getName(), labelType);
+  }
+
+  /** Allows a mutation of an existing {@link LabelType}. */
+  public void updateLabelType(String name, Consumer<LabelType.Builder> update) {
+    LabelType labelType = labelSections.get(name);
+    checkState(labelType != null, "labelType must not be null");
+    LabelType.Builder builder = labelSections.get(name).toBuilder();
+    update.accept(builder);
+    upsertLabelType(builder.build());
+  }
+
+  /** Adds or replaces the given {@link ContributorAgreement} in this config. */
+  public void upsertContributorAgreement(ContributorAgreement ca) {
+    contributorAgreements.remove(ca.getName());
+    contributorAgreements.put(ca.getName(), ca);
+  }
+
+  public Collection<StoredCommentLinkInfo> getCommentLinkSections() {
     return commentLinkSections.values();
   }
 
@@ -485,13 +538,11 @@
   }
 
   public GroupReference resolve(GroupReference group) {
-    GroupReference groupRef = groupList.resolve(group);
-    if (groupRef != null
-        && groupRef.getUUID() != null
-        && !groupsByName.containsKey(groupRef.getName())) {
-      groupsByName.put(groupRef.getName(), groupRef);
-    }
-    return groupRef;
+    return groupList.resolve(group);
+  }
+
+  public void renameGroup(AccountGroup.UUID uuid, String newName) {
+    groupList.renameGroup(uuid, newName);
   }
 
   /** @return the group reference, if the group is used by at least one rule. */
@@ -504,12 +555,7 @@
    *     at least one rule or plugin value.
    */
   public GroupReference getGroup(String groupName) {
-    return groupsByName.get(groupName);
-  }
-
-  /** @return set of all groups used by this configuration. */
-  public Set<AccountGroup.UUID> getAllGroupUUIDs() {
-    return groupList.uuids();
+    return groupList.byName(groupName);
   }
 
   /**
@@ -541,7 +587,7 @@
       GroupDescription.Basic g = groupBackend.get(ref.getUUID());
       if (g != null && !g.getName().equals(ref.getName())) {
         dirty = true;
-        ref.setName(g.getName());
+        groupList.renameGroup(ref.getUUID(), g.getName());
       }
     }
     return dirty;
@@ -570,17 +616,11 @@
       baseConfig.load();
     }
     readGroupList();
-    groupsByName = mapGroupReferences();
 
     rulesId = getObjectId("rules.pl");
     Config rc = readConfig(PROJECT_CONFIG, baseConfig);
-    project = new Project(projectName);
-
-    Project p = project;
-    p.setDescription(rc.getString(PROJECT, null, KEY_DESCRIPTION));
-    if (p.getDescription() == null) {
-      p.setDescription("");
-    }
+    Project.Builder p = Project.builder(projectName);
+    p.setDescription(Strings.nullToEmpty(rc.getString(PROJECT, null, KEY_DESCRIPTION)));
     if (revision != null) {
       p.setConfigRefState(revision.toObjectId().name());
     }
@@ -588,9 +628,9 @@
     if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
       // The config must not contain more than one parent to inherit from
       // as there is no guarantee which of the parents would be used then.
-      error(new ValidationError(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
+      error(ValidationError.create(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
     }
-    p.setParentName(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
+    p.setParent(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
 
     for (BooleanProjectConfig config : BooleanProjectConfig.values()) {
       p.setBooleanConfig(
@@ -610,6 +650,7 @@
 
     p.setDefaultDashboard(rc.getString(DASHBOARD, null, KEY_DEFAULT));
     p.setLocalDefaultDashboard(rc.getString(DASHBOARD, null, KEY_LOCAL_DEFAULT));
+    this.project = p.build();
 
     loadAccountsSection(rc);
     loadContributorAgreements(rc);
@@ -619,16 +660,17 @@
     loadLabelSections(rc);
     loadCommentLinkSections(rc);
     loadSubscribeSections(rc);
-    mimeTypes = new ConfiguredMimeTypes(projectName.get(), rc);
+    mimeTypes = ConfiguredMimeTypes.create(projectName.get(), rc);
     loadPluginSections(rc);
+    loadProjectLevelConfigs();
     loadReceiveSection(rc);
     loadExtensionPanelSections(rc);
   }
 
   private void loadAccountsSection(Config rc) {
-    accountsSection = new AccountsSection();
-    accountsSection.setSameGroupVisibility(
-        loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, groupsByName, false));
+    accountsSection =
+        AccountsSection.create(
+            loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, false));
   }
 
   private void loadExtensionPanelSections(Config rc) {
@@ -638,7 +680,7 @@
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format(
                     "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
@@ -653,23 +695,21 @@
   private void loadContributorAgreements(Config rc) {
     contributorAgreements = new HashMap<>();
     for (String name : rc.getSubsections(CONTRIBUTOR_AGREEMENT)) {
-      ContributorAgreement ca = getContributorAgreement(name, true);
+      ContributorAgreement.Builder ca = ContributorAgreement.builder(name);
       ca.setDescription(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_DESCRIPTION));
       ca.setAgreementUrl(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_AGREEMENT_URL));
-      ca.setAccepted(
-          loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false));
+      ca.setAccepted(loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, false));
       ca.setExcludeProjectsRegexes(
           loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_EXCLUDE_PROJECTS));
       ca.setMatchProjectsRegexes(loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_MATCH_PROJECTS));
 
       List<PermissionRule> rules =
-          loadPermissionRules(
-              rc, CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY, groupsByName, false);
+          loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY, false);
       if (rules.isEmpty()) {
         ca.setAutoVerify(null);
       } else if (rules.size() > 1) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 "Invalid rule in "
                     + CONTRIBUTOR_AGREEMENT
@@ -680,7 +720,7 @@
                     + ": at most one group may be set"));
       } else if (rules.get(0).getAction() != Action.ALLOW) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 "Invalid rule in "
                     + CONTRIBUTOR_AGREEMENT
@@ -692,6 +732,7 @@
       } else {
         ca.setAutoVerify(rules.get(0).getGroup());
       }
+      contributorAgreements.put(name, ca.build());
     }
   }
 
@@ -716,45 +757,44 @@
   private void loadNotifySections(Config rc) {
     notifySections = new HashMap<>();
     for (String sectionName : rc.getSubsections(NOTIFY)) {
-      NotifyConfig n = new NotifyConfig();
+      NotifyConfig.Builder n = NotifyConfig.builder();
       n.setName(sectionName);
       n.setFilter(rc.getString(NOTIFY, sectionName, KEY_FILTER));
 
       EnumSet<NotifyType> types = EnumSet.noneOf(NotifyType.class);
       types.addAll(ConfigUtil.getEnumList(rc, NOTIFY, sectionName, KEY_TYPE, NotifyType.ALL));
-      n.setTypes(types);
+      n.setNotify(types);
       n.setHeader(rc.getEnum(NOTIFY, sectionName, KEY_HEADER, NotifyConfig.Header.BCC));
 
       for (String dst : rc.getStringList(NOTIFY, sectionName, KEY_EMAIL)) {
         String groupName = GroupReference.extractGroupName(dst);
         if (groupName != null) {
-          GroupReference ref = groupsByName.get(groupName);
+          GroupReference ref = groupList.byName(groupName);
           if (ref == null) {
-            ref = new GroupReference(groupName);
-            groupsByName.put(ref.getName(), ref);
+            ref = groupList.resolve(GroupReference.create(groupName));
           }
           if (ref.getUUID() != null) {
-            n.addEmail(ref);
+            n.addGroup(ref);
           } else {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG,
                     "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
           }
         } else if (dst.startsWith("user ")) {
-          error(new ValidationError(PROJECT_CONFIG, dst + " not supported"));
+          error(ValidationError.create(PROJECT_CONFIG, dst + " not supported"));
         } else {
           try {
-            n.addEmail(Address.parse(dst));
+            n.addAddress(Address.parse(dst));
           } catch (IllegalArgumentException err) {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG,
                     "notify section \"" + sectionName + "\" has invalid email \"" + dst + "\""));
           }
         }
       }
-      notifySections.put(sectionName, n);
+      notifySections.put(sectionName, n.build());
     }
   }
 
@@ -763,45 +803,41 @@
     sectionsWithUnknownPermissions = new HashSet<>();
     for (String refName : rc.getSubsections(ACCESS)) {
       if (AccessSection.isValidRefSectionName(refName) && isValidRegex(refName)) {
-        AccessSection as = getAccessSection(refName, true);
+        upsertAccessSection(
+            refName,
+            as -> {
+              for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
+                for (String n : Splitter.on(EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN).split(varName)) {
+                  n = convertLegacyPermission(n);
+                  if (isCoreOrPluginPermission(n)) {
+                    as.upsertPermission(n).setExclusiveGroup(true);
+                  }
+                }
+              }
 
-        for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
-          for (String n : Splitter.on(EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN).split(varName)) {
-            n = convertLegacyPermission(n);
-            if (isCoreOrPluginPermission(n)) {
-              as.getPermission(n, true).setExclusiveGroup(true);
-            }
-          }
-        }
-
-        for (String varName : rc.getNames(ACCESS, refName)) {
-          String convertedName = convertLegacyPermission(varName);
-          if (isCoreOrPluginPermission(convertedName)) {
-            Permission perm = as.getPermission(convertedName, true);
-            loadPermissionRules(
-                rc,
-                ACCESS,
-                refName,
-                varName,
-                groupsByName,
-                perm,
-                Permission.hasRange(convertedName));
-          } else {
-            sectionsWithUnknownPermissions.add(as.getName());
-          }
-        }
+              for (String varName : rc.getNames(ACCESS, refName)) {
+                String convertedName = convertLegacyPermission(varName);
+                if (isCoreOrPluginPermission(convertedName)) {
+                  Permission.Builder perm = as.upsertPermission(convertedName);
+                  loadPermissionRules(
+                      rc, ACCESS, refName, varName, perm, Permission.hasRange(convertedName));
+                } else {
+                  sectionsWithUnknownPermissions.add(as.getName());
+                }
+              }
+            });
       }
     }
 
-    AccessSection capability = null;
+    AccessSection.Builder capability = null;
     for (String varName : rc.getNames(CAPABILITY)) {
       if (capability == null) {
-        capability = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
-        accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability);
+        capability = AccessSection.builder(AccessSection.GLOBAL_CAPABILITIES);
+        accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability.build());
       }
-      Permission perm = capability.getPermission(varName, true);
-      loadPermissionRules(
-          rc, CAPABILITY, null, varName, groupsByName, perm, GlobalCapability.hasRange(varName));
+      Permission.Builder perm = capability.upsertPermission(varName);
+      loadPermissionRules(rc, CAPABILITY, null, varName, perm, GlobalCapability.hasRange(varName));
+      accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability.build());
     }
   }
 
@@ -815,7 +851,7 @@
     try {
       RefPattern.validateRegExp(refPattern);
     } catch (InvalidNameException e) {
-      error(new ValidationError(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
+      error(ValidationError.create(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
       return false;
     }
     return true;
@@ -823,7 +859,14 @@
 
   private void loadBranchOrderSection(Config rc) {
     if (rc.getSections().contains(BRANCH_ORDER)) {
-      branchOrderSection = new BranchOrderSection(rc.getStringList(BRANCH_ORDER, null, BRANCH));
+      branchOrderSection =
+          BranchOrderSection.create(Arrays.asList(rc.getStringList(BRANCH_ORDER, null, BRANCH)));
+    }
+  }
+
+  private void saveBranchOrderSection(Config rc) {
+    if (branchOrderSection != null) {
+      rc.setStringList(BRANCH_ORDER, null, BRANCH, branchOrderSection.order());
     }
   }
 
@@ -836,7 +879,9 @@
         // to fail fast if any of the patterns are invalid.
         patterns.add(Pattern.compile(patternString).pattern());
       } catch (PatternSyntaxException e) {
-        error(new ValidationError(PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
+        error(
+            ValidationError.create(
+                PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
         continue;
       }
     }
@@ -844,15 +889,10 @@
   }
 
   private ImmutableList<PermissionRule> loadPermissionRules(
-      Config rc,
-      String section,
-      String subsection,
-      String varName,
-      Map<String, GroupReference> groupsByName,
-      boolean useRange) {
-    Permission perm = new Permission(varName);
-    loadPermissionRules(rc, section, subsection, varName, groupsByName, perm, useRange);
-    return ImmutableList.copyOf(perm.getRules());
+      Config rc, String section, String subsection, String varName, boolean useRange) {
+    Permission.Builder perm = Permission.builder(varName);
+    loadPermissionRules(rc, section, subsection, varName, perm, useRange);
+    return ImmutableList.copyOf(perm.build().getRules());
   }
 
   private void loadPermissionRules(
@@ -860,8 +900,7 @@
       String section,
       String subsection,
       String varName,
-      Map<String, GroupReference> groupsByName,
-      Permission perm,
+      Permission.Builder perm,
       boolean useRange) {
     for (String ruleString : rc.getStringList(section, subsection, varName)) {
       PermissionRule rule;
@@ -869,7 +908,7 @@
         rule = PermissionRule.fromString(ruleString, useRange);
       } catch (IllegalArgumentException notRule) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 "Invalid rule in "
                     + section
@@ -881,21 +920,19 @@
         continue;
       }
 
-      GroupReference ref = groupsByName.get(rule.getGroup().getName());
+      GroupReference ref = groupList.byName(rule.getGroup().getName());
       if (ref == null) {
         // The group wasn't mentioned in the groups table, so there is
         // no valid UUID for it. Pool the reference anyway so at least
         // all rules in the same file share the same GroupReference.
         //
-        ref = rule.getGroup();
-        groupsByName.put(ref.getName(), ref);
+        ref = groupList.resolve(rule.getGroup());
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG, "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
       }
 
-      rule.setGroup(ref);
-      perm.add(rule);
+      perm.add(rule.toBuilder().setGroup(ref));
     }
   }
 
@@ -907,7 +944,7 @@
       throw new IllegalArgumentException("empty value");
     }
     String valueText = parts.size() > 1 ? parts.get(1) : "";
-    return new LabelValue(Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))), valueText);
+    return LabelValue.create(Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))), valueText);
   }
 
   private void loadLabelSections(Config rc) {
@@ -917,7 +954,7 @@
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
       }
@@ -932,13 +969,13 @@
             values.add(labelValue);
           } else {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG,
                     String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_VALUE, value, name)));
           }
         } catch (IllegalArgumentException notValue) {
           error(
-              new ValidationError(
+              ValidationError.create(
                   PROJECT_CONFIG,
                   String.format(
                       "Invalid %s \"%s\" for label \"%s\": %s",
@@ -946,11 +983,11 @@
         }
       }
 
-      LabelType label;
+      LabelType.Builder label;
       try {
-        label = new LabelType(name, values);
+        label = LabelType.builder(name, values);
       } catch (IllegalArgumentException badName) {
-        error(new ValidationError(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
+        error(ValidationError.create(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
         continue;
       }
 
@@ -961,7 +998,7 @@
               : Optional.of(LabelFunction.MAX_WITH_BLOCK);
       if (!function.isPresent()) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format(
                     "Invalid %s for label \"%s\". Valid names are: %s",
@@ -975,7 +1012,7 @@
           label.setDefaultValue(dv);
         } else {
           error(
-              new ValidationError(
+              ValidationError.create(
                   PROJECT_CONFIG,
                   String.format(
                       "Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name)));
@@ -1021,14 +1058,14 @@
           short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
           if (!copyValues.add(copyValue)) {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG,
                     String.format(
                         "Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name)));
           }
         } catch (IllegalArgumentException notValue) {
           error(
-              new ValidationError(
+              ValidationError.create(
                   PROJECT_CONFIG,
                   String.format(
                       "Invalid %s \"%s\" for label \"%s\": %s",
@@ -1038,8 +1075,9 @@
       label.setCopyValues(copyValues);
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
-      label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_BRANCH));
-      labelSections.put(name, label);
+      List<String> refPatterns = getStringListOrNull(rc, LABEL, name, KEY_BRANCH);
+      label.setRefPatterns(refPatterns == null ? null : ImmutableList.copyOf(refPatterns));
+      labelSections.put(name, label.build());
     }
   }
 
@@ -1066,14 +1104,14 @@
         commentLinkSections.put(name, buildCommentLink(rc, name, false));
       } catch (PatternSyntaxException e) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format(
                     "Invalid pattern \"%s\" in commentlink.%s.match: %s",
                     rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
       } catch (IllegalArgumentException e) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format(
                     "Error in pattern \"%s\" in commentlink.%s.match: %s",
@@ -1088,7 +1126,7 @@
     try {
       for (String projectName : subsections) {
         Project.NameKey p = Project.nameKey(projectName);
-        SubscribeSection ss = new SubscribeSection(p);
+        SubscribeSection.Builder ss = SubscribeSection.builder(p);
         for (String s :
             rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
           ss.addMultiMatchRefSpec(s);
@@ -1096,7 +1134,7 @@
         for (String s : rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MATCH_REFS)) {
           ss.addMatchingRefSpec(s);
         }
-        subscribeSections.put(p, ss);
+        subscribeSections.put(p, ss.build());
       }
     } catch (IllegalArgumentException e) {
       throw new ConfigInvalidException(e.getMessage());
@@ -1117,10 +1155,10 @@
         String value = rc.getString(PLUGIN, plugin, name);
         String groupName = GroupReference.extractGroupName(value);
         if (groupName != null) {
-          GroupReference ref = groupsByName.get(groupName);
+          GroupReference ref = groupList.byName(groupName);
           if (ref == null) {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG, "group \"" + groupName + "\" not in " + GroupList.FILE_NAME));
           }
           rc.setString(PLUGIN, plugin, name, value);
@@ -1131,29 +1169,44 @@
     }
   }
 
-  public PluginConfig getPluginConfig(String pluginName) {
+  public void updatePluginConfig(
+      String pluginName, Consumer<PluginConfig.Update> pluginConfigUpdate) {
     Config pluginConfig = pluginConfigs.get(pluginName);
     if (pluginConfig == null) {
       pluginConfig = new Config();
       pluginConfigs.put(pluginName, pluginConfig);
     }
-    return new PluginConfig(pluginName, pluginConfig, this);
+    pluginConfigUpdate.accept(new PluginConfig.Update(pluginName, pluginConfig, Optional.of(this)));
+  }
+
+  public PluginConfig getPluginConfig(String pluginName) {
+    Config pluginConfig = pluginConfigs.getOrDefault(pluginName, new Config());
+    return PluginConfig.create(pluginName, pluginConfig, getCacheable());
+  }
+
+  private void loadProjectLevelConfigs() throws IOException {
+    projectLevelConfigs = new HashMap<>();
+    if (revision == null) {
+      return;
+    }
+    for (PathInfo pathInfo : getPathInfos(true)) {
+      if (pathInfo.path.endsWith(".config") && !PROJECT_CONFIG.equals(pathInfo.path)) {
+        String cfg = readUTF8(pathInfo.path);
+        Config parsedConfig = new Config();
+        try {
+          parsedConfig.fromText(cfg);
+          projectLevelConfigs.put(pathInfo.path, parsedConfig);
+        } catch (ConfigInvalidException e) {
+          logger.atWarning().withCause(e).log("Unable to parse config");
+        }
+      }
+    }
   }
 
   private void readGroupList() throws IOException {
     groupList = GroupList.parse(projectName, readUTF8(GroupList.FILE_NAME), this);
   }
 
-  private Map<String, GroupReference> mapGroupReferences() {
-    Collection<GroupReference> references = groupList.references();
-    Map<String, GroupReference> result = new HashMap<>(references.size());
-    for (GroupReference ref : references) {
-      result.put(ref.getName(), ref);
-    }
-
-    return result;
-  }
-
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     if (commit.getMessage() == null || "".equals(commit.getMessage())) {
@@ -1187,7 +1240,7 @@
         KEY_MAX_OBJECT_SIZE_LIMIT,
         validMaxObjectSizeLimit(p.getMaxObjectSizeLimit()));
 
-    set(rc, SUBMIT, null, KEY_ACTION, p.getConfiguredSubmitType(), DEFAULT_SUBMIT_TYPE);
+    set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), DEFAULT_SUBMIT_TYPE);
 
     set(rc, PROJECT, null, KEY_STATE, p.getState(), DEFAULT_STATE_VALUE);
 
@@ -1204,6 +1257,7 @@
     saveLabelSections(rc);
     saveCommentLinkSections(rc);
     saveSubscribeSections(rc);
+    saveBranchOrderSection(rc);
 
     saveConfig(PROJECT_CONFIG, rc);
     saveGroupList();
@@ -1252,16 +1306,16 @@
   private void saveCommentLinkSections(Config rc) {
     unsetSection(rc, COMMENTLINK);
     if (commentLinkSections != null) {
-      for (CommentLinkInfoImpl cm : commentLinkSections.values()) {
-        rc.setString(COMMENTLINK, cm.name, KEY_MATCH, cm.match);
-        if (!Strings.isNullOrEmpty(cm.html)) {
-          rc.setString(COMMENTLINK, cm.name, KEY_HTML, cm.html);
+      for (StoredCommentLinkInfo cm : commentLinkSections.values()) {
+        rc.setString(COMMENTLINK, cm.getName(), KEY_MATCH, cm.getMatch());
+        if (!Strings.isNullOrEmpty(cm.getHtml())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_HTML, cm.getHtml());
         }
-        if (!Strings.isNullOrEmpty(cm.link)) {
-          rc.setString(COMMENTLINK, cm.name, KEY_LINK, cm.link);
+        if (!Strings.isNullOrEmpty(cm.getLink())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_LINK, cm.getLink());
         }
-        if (cm.enabled != null && !cm.enabled) {
-          rc.setBoolean(COMMENTLINK, cm.name, KEY_ENABLED, cm.enabled);
+        if (cm.getEnabled() != null && !cm.getEnabled()) {
+          rc.setBoolean(COMMENTLINK, cm.getName(), KEY_ENABLED, cm.getEnabled());
         }
       }
     }
@@ -1277,7 +1331,7 @@
         if (ca.getAutoVerify().getUUID() != null) {
           keepGroups.add(ca.getAutoVerify().getUUID());
         }
-        String autoVerify = new PermissionRule(ca.getAutoVerify()).asString(false);
+        String autoVerify = PermissionRule.create(ca.getAutoVerify()).asString(false);
         set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AUTO_VERIFY, autoVerify);
       } else {
         rc.unset(CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AUTO_VERIFY);
@@ -1310,7 +1364,7 @@
           .forEach(keepGroups::add);
       List<String> email =
           nc.getGroups().stream()
-              .map(gr -> new PermissionRule(gr).asString(false))
+              .map(gr -> PermissionRule.create(gr).asString(false))
               .sorted()
               .collect(toList());
 
@@ -1324,7 +1378,7 @@
         rc.setStringList(NOTIFY, nc.getName(), KEY_EMAIL, email);
       }
 
-      if (nc.getNotify().equals(EnumSet.of(NotifyType.ALL))) {
+      if (nc.getNotify().equals(Sets.immutableEnumSet(NotifyType.ALL))) {
         rc.unset(NOTIFY, nc.getName(), KEY_TYPE);
       } else {
         List<String> types = new ArrayList<>(4);
@@ -1371,7 +1425,7 @@
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
-          rules.add(rule.asString(needRange));
+          rules.add(rule.toBuilder().setGroup(group).build().asString(needRange));
         }
         rc.setStringList(CAPABILITY, null, permission.getName(), rules);
       }
@@ -1416,7 +1470,7 @@
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
-          rules.add(rule.asString(needRange));
+          rules.add(rule.toBuilder().setGroup(group).build().asString(needRange));
         }
         rc.setStringList(ACCESS, refName, permission.getName(), rules);
       }
@@ -1456,14 +1510,14 @@
           LABEL,
           name,
           KEY_ALLOW_POST_SUBMIT,
-          label.allowPostSubmit(),
+          label.isAllowPostSubmit(),
           LabelType.DEF_ALLOW_POST_SUBMIT);
       setBooleanConfigKey(
           rc,
           LABEL,
           name,
           KEY_IGNORE_SELF_APPROVAL,
-          label.ignoreSelfApproval(),
+          label.isIgnoreSelfApproval(),
           LabelType.DEF_IGNORE_SELF_APPROVAL);
       setBooleanConfigKey(
           rc,
@@ -1520,7 +1574,7 @@
           KEY_COPY_VALUE,
           label.getCopyValues().stream().map(LabelValue::formatValue).collect(toList()));
       setBooleanConfigKey(
-          rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
+          rc, LABEL, name, KEY_CAN_OVERRIDE, label.isCanOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = new ArrayList<>(label.getValues().size());
       for (LabelValue value : label.getValues()) {
         values.add(value.format().trim());
@@ -1558,7 +1612,7 @@
         String value = pluginConfig.getString(PLUGIN, plugin, name);
         String groupName = GroupReference.extractGroupName(value);
         if (groupName != null) {
-          GroupReference ref = groupsByName.get(groupName);
+          GroupReference ref = groupList.byName(groupName);
           if (ref != null && ref.getUUID() != null) {
             keepGroups.add(ref.getUUID());
             pluginConfig.setString(PLUGIN, plugin, name, "group " + ref.getName());
@@ -1578,14 +1632,14 @@
     for (Project.NameKey p : subscribeSections.keySet()) {
       SubscribeSection s = subscribeSections.get(p);
       List<String> matchings = new ArrayList<>();
-      for (RefSpec r : s.getMatchingRefSpecs()) {
-        matchings.add(r.toString());
+      for (String r : s.matchingRefSpecsAsString()) {
+        matchings.add(r);
       }
       rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MATCH_REFS, matchings);
 
       List<String> multimatchs = new ArrayList<>();
-      for (RefSpec r : s.getMultiMatchRefSpecs()) {
-        multimatchs.add(r.toString());
+      for (String r : s.multiMatchRefSpecsAsString()) {
+        multimatchs.add(r);
       }
       rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MULTI_MATCH_REFS, multimatchs);
     }
@@ -1603,7 +1657,7 @@
     try {
       return rc.getEnum(section, subsection, name, defaultValue);
     } catch (IllegalArgumentException err) {
-      error(new ValidationError(PROJECT_CONFIG, err.getMessage()));
+      error(ValidationError.create(PROJECT_CONFIG, err.getMessage()));
       return defaultValue;
     }
   }
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index cc10f27..c382f04 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -17,14 +17,15 @@
 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.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
@@ -150,36 +151,45 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
       ProjectConfig config = projectConfigFactory.read(md);
 
-      Project newProject = config.getProject();
-      newProject.setDescription(args.projectDescription);
-      newProject.setSubmitType(
-          MoreObjects.firstNonNull(
-              args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
-      newProject.setBooleanConfig(
-          BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, args.contributorAgreements);
-      newProject.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, args.signedOffBy);
-      newProject.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, args.contentMerge);
-      newProject.setBooleanConfig(
-          BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
-          args.newChangeForAllNotInTarget);
-      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
-      newProject.setBooleanConfig(BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
-      newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
-      newProject.setBooleanConfig(BooleanProjectConfig.ENABLE_SIGNED_PUSH, args.enableSignedPush);
-      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_SIGNED_PUSH, args.requireSignedPush);
-      if (args.newParent != null) {
-        newProject.setParentName(args.newParent);
-      }
+      config.updateProject(
+          newProject -> {
+            newProject.setDescription(Strings.nullToEmpty(args.projectDescription));
+            newProject.setSubmitType(
+                MoreObjects.firstNonNull(
+                    args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, args.contributorAgreements);
+            newProject.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, args.signedOffBy);
+            newProject.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, args.contentMerge);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+                args.newChangeForAllNotInTarget);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
+            newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.ENABLE_SIGNED_PUSH, args.enableSignedPush);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.REQUIRE_SIGNED_PUSH, args.requireSignedPush);
+            if (args.newParent != null) {
+              newProject.setParent(args.newParent);
+            }
+          });
 
       if (!args.ownerIds.isEmpty()) {
-        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-        for (AccountGroup.UUID ownerId : args.ownerIds) {
-          GroupDescription.Basic g = groupBackend.get(ownerId);
-          if (g != null) {
-            GroupReference group = config.resolve(GroupReference.forGroup(g));
-            all.getPermission(Permission.OWNER, true).add(new PermissionRule(group));
-          }
-        }
+        config.upsertAccessSection(
+            AccessSection.ALL,
+            all -> {
+              for (AccountGroup.UUID ownerId : args.ownerIds) {
+                GroupDescription.Basic g = groupBackend.get(ownerId);
+                if (g != null) {
+                  GroupReference group = config.resolve(GroupReference.forGroup(g));
+                  all.upsertPermission(Permission.OWNER).add(PermissionRule.builder(group));
+                }
+              }
+            });
       }
 
       md.setMessage("Created project\n");
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index f00df53..4eda1cc 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -19,8 +19,8 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.LabelTypeInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 4e0261c..4825233 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -24,29 +24,61 @@
 import java.util.Arrays;
 import java.util.Set;
 import java.util.stream.Stream;
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 
 /** Configuration file in the projects refs/meta/config branch. */
-public class ProjectLevelConfig extends VersionedMetaData {
+public class ProjectLevelConfig {
+  /**
+   * This class is a low-level API that allows callers to read the config directly from a repository
+   * and make updates to it.
+   */
+  public static class Bare extends VersionedMetaData {
+    private final String fileName;
+    @Nullable private Config cfg;
+
+    public Bare(String fileName) {
+      this.fileName = fileName;
+      this.cfg = null;
+    }
+
+    public Config getConfig() {
+      if (cfg == null) {
+        cfg = new Config();
+      }
+      return cfg;
+    }
+
+    @Override
+    protected String getRefName() {
+      return RefNames.REFS_CONFIG;
+    }
+
+    @Override
+    protected void onLoad() throws IOException, ConfigInvalidException {
+      cfg = readConfig(fileName);
+    }
+
+    @Override
+    protected boolean onSave(CommitBuilder commit) throws IOException {
+      if (commit.getMessage() == null || "".equals(commit.getMessage())) {
+        commit.setMessage("Updated configuration\n");
+      }
+      saveConfig(fileName, cfg);
+      return true;
+    }
+  }
+
   private final String fileName;
   private final ProjectState project;
   private Config cfg;
 
-  public ProjectLevelConfig(String fileName, ProjectState project) {
+  public ProjectLevelConfig(String fileName, ProjectState project, Config cfg) {
     this.fileName = fileName;
     this.project = project;
-  }
-
-  @Override
-  protected String getRefName() {
-    return RefNames.REFS_CONFIG;
-  }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    cfg = readConfig(fileName);
+    this.cfg = cfg;
   }
 
   public Config get() {
@@ -127,13 +159,4 @@
     }
     return cfgWithInheritance;
   }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-    if (commit.getMessage() == null || "".equals(commit.getMessage())) {
-      commit.setMessage("Updated configuration\n");
-    }
-    saveConfig(fileName, cfg);
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index e52f344..eecf1fe 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -14,25 +14,27 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.gerrit.common.data.PermissionRule.Action.ALLOW;
+import static com.google.gerrit.entities.PermissionRule.Action.ALLOW;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -40,27 +42,22 @@
 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.config.PluginConfig;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 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.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * Cached information on a project. Must not contain any data derived from parents other than it's
@@ -70,25 +67,20 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    ProjectState create(ProjectConfig config);
+    ProjectState create(CachedProjectConfig config);
   }
 
   private final boolean isAllProjects;
   private final boolean isAllUsers;
   private final AllProjectsName allProjectsName;
   private final ProjectCache projectCache;
-  private final GitRepositoryManager gitMgr;
   private final List<CommentLinkInfo> commentLinks;
 
-  private final ProjectConfig config;
-  private final Map<String, ProjectLevelConfig> configs;
+  private final CachedProjectConfig cachedConfig;
   private final Set<AccountGroup.UUID> localOwners;
   private final long globalMaxObjectSizeLimit;
   private final boolean inheritProjectMaxObjectSizeLimit;
 
-  /** Last system time the configuration's revision was examined. */
-  private volatile long lastCheckGeneration;
-
   /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
   private volatile List<SectionMatcher> localAccessSections;
 
@@ -100,22 +92,22 @@
       ProjectCache projectCache,
       AllProjectsName allProjectsName,
       AllUsersName allUsersName,
-      GitRepositoryManager gitMgr,
       List<CommentLinkInfo> commentLinks,
       CapabilityCollection.Factory limitsFactory,
       TransferConfig transferConfig,
-      @Assisted ProjectConfig config) {
+      @Assisted CachedProjectConfig cachedProjectConfig) {
     this.projectCache = projectCache;
-    this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
-    this.isAllUsers = config.getProject().getNameKey().equals(allUsersName);
+    this.isAllProjects = cachedProjectConfig.getProject().getNameKey().equals(allProjectsName);
+    this.isAllUsers = cachedProjectConfig.getProject().getNameKey().equals(allUsersName);
     this.allProjectsName = allProjectsName;
-    this.gitMgr = gitMgr;
     this.commentLinks = commentLinks;
-    this.config = config;
-    this.configs = new HashMap<>();
+    this.cachedConfig = cachedProjectConfig;
     this.capabilities =
         isAllProjects
-            ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
+            ? limitsFactory.create(
+                cachedProjectConfig
+                    .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+                    .orElse(null))
             : null;
     this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
     this.inheritProjectMaxObjectSizeLimit = transferConfig.inheritProjectMaxObjectSizeLimit();
@@ -124,9 +116,9 @@
       localOwners = Collections.emptySet();
     } else {
       HashSet<AccountGroup.UUID> groups = new HashSet<>();
-      AccessSection all = config.getAccessSection(AccessSection.ALL);
-      if (all != null) {
-        Permission owner = all.getPermission(Permission.OWNER);
+      Optional<AccessSection> all = cachedProjectConfig.getAccessSection(AccessSection.ALL);
+      if (all.isPresent()) {
+        Permission owner = all.get().getPermission(Permission.OWNER);
         if (owner != null) {
           for (PermissionRule rule : owner.getRules()) {
             GroupReference ref = rule.getGroup();
@@ -140,33 +132,6 @@
     }
   }
 
-  void initLastCheck(long generation) {
-    lastCheckGeneration = generation;
-  }
-
-  boolean needsRefresh(long generation) {
-    if (generation <= 0) {
-      return isRevisionOutOfDate();
-    }
-    if (lastCheckGeneration != generation) {
-      lastCheckGeneration = generation;
-      return isRevisionOutOfDate();
-    }
-    return false;
-  }
-
-  private boolean isRevisionOutOfDate() {
-    try (Repository git = gitMgr.openRepository(getNameKey())) {
-      Ref ref = git.getRefDatabase().exactRef(RefNames.REFS_CONFIG);
-      if (ref == null || ref.getObjectId() == null) {
-        return true;
-      }
-      return !ref.getObjectId().equals(config.getRevision());
-    } catch (IOException gone) {
-      return true;
-    }
-  }
-
   /**
    * @return cached computation of all global capabilities. This should only be invoked on the state
    *     from {@link ProjectCache#getAllProjects()}. Null on any other project.
@@ -181,19 +146,19 @@
    */
   public boolean hasPrologRules() {
     // We check if this project has a rules.pl file
-    if (getConfig().getRulesId() != null) {
+    if (getConfig().getRulesId().isPresent()) {
       return true;
     }
 
     // If not, we check the parents.
     return parents().stream()
         .map(ProjectState::getConfig)
-        .map(ProjectConfig::getRulesId)
-        .anyMatch(Objects::nonNull);
+        .map(CachedProjectConfig::getRulesId)
+        .anyMatch(Optional::isPresent);
   }
 
   public Project getProject() {
-    return config.getProject();
+    return cachedConfig.getProject();
   }
 
   public Project.NameKey getNameKey() {
@@ -204,28 +169,17 @@
     return getNameKey().get();
   }
 
-  public ProjectConfig getConfig() {
-    return config;
+  public CachedProjectConfig getConfig() {
+    return cachedConfig;
   }
 
   public ProjectLevelConfig getConfig(String fileName) {
-    if (configs.containsKey(fileName)) {
-      return configs.get(fileName);
-    }
-
-    ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
-    try (Repository git = gitMgr.openRepository(getNameKey())) {
-      cfg.load(getNameKey(), git, config.getRevision());
-    } catch (IOException | ConfigInvalidException e) {
-      logger.atWarning().withCause(e).log("Failed to load %s for %s", fileName, getName());
-    }
-
-    configs.put(fileName, cfg);
-    return cfg;
+    Optional<Config> rawConfig = cachedConfig.getProjectLevelConfig(fileName);
+    return new ProjectLevelConfig(fileName, this, rawConfig.orElse(new Config()));
   }
 
   public long getMaxObjectSizeLimit() {
-    return config.getMaxObjectSizeLimit();
+    return cachedConfig.getMaxObjectSizeLimit();
   }
 
   public boolean statePermitsRead() {
@@ -274,19 +228,21 @@
   public EffectiveMaxObjectSizeLimit getEffectiveMaxObjectSizeLimit() {
     EffectiveMaxObjectSizeLimit result = new EffectiveMaxObjectSizeLimit();
 
-    result.value = config.getMaxObjectSizeLimit();
+    result.value = cachedConfig.getMaxObjectSizeLimit();
 
     if (inheritProjectMaxObjectSizeLimit) {
       for (ProjectState parent : parents()) {
-        long parentValue = parent.config.getMaxObjectSizeLimit();
+        long parentValue = parent.cachedConfig.getMaxObjectSizeLimit();
         if (parentValue > 0 && result.value > 0) {
           if (parentValue < result.value) {
             result.value = parentValue;
-            result.summary = String.format(OVERRIDDEN_BY_PARENT, parent.config.getName());
+            result.summary =
+                String.format(OVERRIDDEN_BY_PARENT, parent.cachedConfig.getProject().getNameKey());
           }
         } else if (parentValue > 0) {
           result.value = parentValue;
-          result.summary = String.format(INHERITED_FROM_PARENT, parent.config.getName());
+          result.summary =
+              String.format(INHERITED_FROM_PARENT, parent.cachedConfig.getProject().getNameKey());
         }
       }
     }
@@ -308,18 +264,20 @@
   List<SectionMatcher> getLocalAccessSections() {
     List<SectionMatcher> sm = localAccessSections;
     if (sm == null) {
-      Collection<AccessSection> fromConfig = config.getAccessSections();
+      Collection<AccessSection> fromConfig = cachedConfig.getAccessSections().values();
       sm = new ArrayList<>(fromConfig.size());
       for (AccessSection section : fromConfig) {
         if (isAllProjects) {
-          List<Permission> copy = Lists.newArrayListWithCapacity(section.getPermissions().size());
+          List<Permission.Builder> copy = new ArrayList<>();
           for (Permission p : section.getPermissions()) {
             if (Permission.canBeOnAllProjects(section.getName(), p.getName())) {
-              copy.add(p);
+              copy.add(p.toBuilder());
             }
           }
-          section = new AccessSection(section.getName());
-          section.setPermissions(copy);
+          section =
+              AccessSection.builder(section.getName())
+                  .modifyPermissions(permissions -> permissions.addAll(copy))
+                  .build();
         }
 
         SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
@@ -434,7 +392,7 @@
       for (LabelType type : s.getConfig().getLabelSections().values()) {
         String lower = type.getName().toLowerCase();
         LabelType old = types.get(lower);
-        if (old == null || old.canOverride()) {
+        if (old == null || old.isCanOverride()) {
           types.put(lower, type);
         }
       }
@@ -489,30 +447,52 @@
       cls.put(cl.name.toLowerCase(), cl);
     }
     for (ProjectState s : treeInOrder()) {
-      for (CommentLinkInfoImpl cl : s.getConfig().getCommentLinkSections()) {
-        String name = cl.name.toLowerCase();
-        if (cl.isOverrideOnly()) {
+      for (StoredCommentLinkInfo cl : s.getConfig().getCommentLinkSections().values()) {
+        String name = cl.getName().toLowerCase();
+        if (cl.getOverrideOnly()) {
           CommentLinkInfo parent = cls.get(name);
           if (parent == null) {
             continue; // Ignore invalid overrides.
           }
-          cls.put(name, cl.inherit(parent));
+          cls.put(name, StoredCommentLinkInfo.fromInfo(parent, cl.getEnabled()).toInfo());
         } else {
-          cls.put(name, cl);
+          cls.put(name, cl.toInfo());
         }
       }
     }
     return ImmutableList.copyOf(cls.values());
   }
 
-  public BranchOrderSection getBranchOrderSection() {
+  /**
+   * Returns the {@link PluginConfig} that got parsed from the {@code plugins} section of {@code
+   * project.config}. The returned instance is a defensive copy of the cached value. Returns an
+   * empty config in case we find no config for the given plugin name. This is useful when calling
+   * {@code PluginConfig#withInheritance(ProjectState.Factory)}
+   */
+  public PluginConfig getPluginConfig(String pluginName) {
+    if (getConfig().getPluginConfigs().containsKey(pluginName)) {
+      Config config = new Config();
+      try {
+        config.fromText(getConfig().getPluginConfigs().get(pluginName));
+      } catch (ConfigInvalidException e) {
+        // This is OK to propagate as IllegalStateException because it's a programmer error.
+        // The config was converted to a String using Config#toText. So #fromText must not
+        // throw a ConfigInvalidException
+        throw new IllegalStateException("invalid plugin config for " + pluginName, e);
+      }
+      return PluginConfig.create(pluginName, config, getConfig());
+    }
+    return PluginConfig.create(pluginName, new Config(), getConfig());
+  }
+
+  public Optional<BranchOrderSection> getBranchOrderSection() {
     for (ProjectState s : tree()) {
-      BranchOrderSection section = s.getConfig().getBranchOrderSection();
-      if (section != null) {
+      Optional<BranchOrderSection> section = s.getConfig().getBranchOrderSection();
+      if (section.isPresent()) {
         return section;
       }
     }
-    return null;
+    return Optional.empty();
   }
 
   public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
@@ -533,7 +513,7 @@
 
   public SubmitType getSubmitType() {
     for (ProjectState s : tree()) {
-      SubmitType t = s.getProject().getConfiguredSubmitType();
+      SubmitType t = s.getProject().getSubmitType();
       if (t != SubmitType.INHERIT) {
         return t;
       }
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 7ed2491..812d98d 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.index.query.Predicate.and;
 import static com.google.gerrit.index.query.Predicate.or;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
@@ -36,13 +35,16 @@
 import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo.AutoCloseableChangesCheckResult;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
 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;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -75,17 +77,20 @@
   private final RetryHelper retryHelper;
   private final ChangeJson.Factory changeJsonFactory;
   private final IndexConfig indexConfig;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   ProjectsConsistencyChecker(
       GitRepositoryManager repoManager,
       RetryHelper retryHelper,
       ChangeJson.Factory changeJsonFactory,
-      IndexConfig indexConfig) {
+      IndexConfig indexConfig,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.repoManager = repoManager;
     this.retryHelper = retryHelper;
     this.changeJsonFactory = changeJsonFactory;
     this.indexConfig = indexConfig;
+    this.urlFormatter = urlFormatter;
   }
 
   public CheckProjectResultInfo check(Project.NameKey projectName, CheckProjectInput input)
@@ -172,7 +177,7 @@
         mergedSha1s.add(commitId);
 
         // Consider all Change-Id lines since this is what ReceiveCommits#autoCloseChanges does.
-        List<String> changeIds = commit.getFooterLines(CHANGE_ID);
+        List<String> changeIds = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
 
         // Number of predicates that we need to add for this commit, 1 per Change-Id plus one for
         // the commit.
diff --git a/java/com/google/gerrit/server/project/RefPattern.java b/java/com/google/gerrit/server/project/RefPattern.java
index c52914b..5bac950 100644
--- a/java/com/google/gerrit/server/project/RefPattern.java
+++ b/java/com/google/gerrit/server/project/RefPattern.java
@@ -19,8 +19,8 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.exceptions.InvalidNameException;
 import dk.brics.automaton.RegExp;
 import java.util.Map;
diff --git a/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
index 9b297f9..1912660 100644
--- a/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -43,7 +43,7 @@
       throws ResourceConflictException {
     RefOperationValidators refValidators =
         refValidatorsFactory.create(
-            new Project(Project.nameKey(projectName)),
+            Project.builder(Project.nameKey(projectName)).build(),
             user,
             RefOperationValidators.getCommand(update, operationType));
     try {
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 6de8eec..763957e 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index cf3819d..0e50bb0 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -18,9 +18,9 @@
 
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
index 988a89f..3112b5a 100644
--- a/java/com/google/gerrit/server/project/testing/BUILD
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -5,5 +5,8 @@
     testonly = True,
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
-    deps = ["//java/com/google/gerrit/common:server"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+    ],
 )
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 6c2ddde..157c746 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.project.testing;
 
-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.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import java.util.Arrays;
 
 public class TestLabels {
@@ -35,18 +35,23 @@
   }
 
   public static LabelType patchSetLock() {
-    LabelType label =
-        label("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
+    LabelType.Builder label =
+        labelBuilder(
+            "Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
     label.setFunction(LabelFunction.PATCH_SET_LOCK);
-    return label;
+    return label.build();
   }
 
   public static LabelValue value(int value, String text) {
-    return new LabelValue((short) value, text);
+    return LabelValue.create((short) value, text);
   }
 
   public static LabelType label(String name, LabelValue... values) {
-    return new LabelType(name, Arrays.asList(values));
+    return labelBuilder(name, values).build();
+  }
+
+  public static LabelType.Builder labelBuilder(String name, LabelValue... values) {
+    return LabelType.builder(name, Arrays.asList(values));
   }
 
   private TestLabels() {}
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index 2e29bbd..e380ef1 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -41,6 +42,7 @@
  */
 public class AccountQueryProcessor extends QueryProcessor<AccountState> {
   private final AccountControl.Factory accountControlFactory;
+  private final Sequences sequences;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -57,7 +59,8 @@
       IndexConfig indexConfig,
       AccountIndexCollection indexes,
       AccountIndexRewriter rewriter,
-      AccountControl.Factory accountControlFactory) {
+      AccountControl.Factory accountControlFactory,
+      Sequences sequences) {
     super(
         metricMaker,
         AccountSchemaDefinitions.INSTANCE,
@@ -67,6 +70,7 @@
         FIELD_LIMIT,
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.accountControlFactory = accountControlFactory;
+    this.sequences = sequences;
   }
 
   @Override
@@ -79,4 +83,14 @@
   protected String formatForLogging(AccountState accountState) {
     return accountState.account().id().toString();
   }
+
+  @Override
+  protected int getIndexSize() {
+    return sequences.lastAccountId();
+  }
+
+  @Override
+  protected int getBatchSize() {
+    return sequences.accountBatchSize();
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index c741506..2c43bb7 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -33,19 +33,20 @@
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelTypes;
-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;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -59,6 +60,8 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.StarRef;
+import com.google.gerrit.server.change.CommentThread;
+import com.google.gerrit.server.change.CommentThreads;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
@@ -277,7 +280,7 @@
   private List<PatchSetApproval> currentApprovals;
   private List<String> currentFiles;
   private Optional<DiffSummary> diffSummary;
-  private Collection<Comment> publishedComments;
+  private Collection<HumanComment> publishedComments;
   private Collection<RobotComment> robotComments;
   private CurrentUser visibleTo;
   private List<ChangeMessage> messages;
@@ -675,9 +678,11 @@
   public ReviewerSet reviewers() {
     if (reviewers == null) {
       if (!lazyLoad) {
-        return ReviewerSet.empty();
+        // We are not allowed to load values from NoteDb. Reviewers were not populated with values
+        // from the index. However, we need these values for permission checks.
+        throw new IllegalStateException("reviewers not populated");
       }
-      reviewers = approvalsUtil.getReviewers(notes(), approvals().values());
+      reviewers = approvalsUtil.getReviewers(notes());
     }
     return reviewers;
   }
@@ -686,10 +691,6 @@
     this.reviewers = reviewers;
   }
 
-  public ReviewerSet getReviewers() {
-    return reviewers;
-  }
-
   public ReviewerByEmailSet reviewersByEmail() {
     if (reviewersByEmail == null) {
       if (!lazyLoad) {
@@ -762,12 +763,12 @@
     return reviewerUpdates;
   }
 
-  public Collection<Comment> publishedComments() {
+  public Collection<HumanComment> publishedComments() {
     if (publishedComments == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
       }
-      publishedComments = commentsUtil.publishedByChange(notes());
+      publishedComments = commentsUtil.publishedHumanCommentsByChange(notes());
     }
     return publishedComments;
   }
@@ -791,47 +792,15 @@
       List<Comment> comments =
           Stream.concat(publishedComments().stream(), robotComments().stream()).collect(toList());
 
-      // Build a map of uuid to list of direct descendants.
-      Map<String, List<Comment>> forest = new HashMap<>();
-      for (Comment comment : comments) {
-        List<Comment> siblings = forest.get(comment.parentUuid);
-        if (siblings == null) {
-          siblings = new ArrayList<>();
-          forest.put(comment.parentUuid, siblings);
-        }
-        siblings.add(comment);
-      }
-
-      // Find latest comment in each thread and apply to unresolved counter.
-      int unresolved = 0;
-      if (forest.containsKey(null)) {
-        for (Comment root : forest.get(null)) {
-          if (getLatestComment(forest, root).unresolved) {
-            unresolved++;
-          }
-        }
-      }
-      unresolvedCommentCount = unresolved;
+      ImmutableSet<CommentThread<Comment>> commentThreads =
+          CommentThreads.forComments(comments).getThreads();
+      unresolvedCommentCount =
+          (int) commentThreads.stream().filter(CommentThread::unresolved).count();
     }
 
     return unresolvedCommentCount;
   }
 
-  protected Comment getLatestComment(Map<String, List<Comment>> forest, Comment root) {
-    List<Comment> children = forest.get(root.key.uuid);
-    if (children == null) {
-      return root;
-    }
-    Comment latest = null;
-    for (Comment comment : children) {
-      Comment branchLatest = getLatestComment(forest, comment);
-      if (latest == null || branchLatest.writtenOn.after(latest.writtenOn)) {
-        latest = branchLatest;
-      }
-    }
-    return latest;
-  }
-
   public void setUnresolvedCommentCount(Integer count) {
     this.unresolvedCommentCount = count;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 40a3a07..c6bcd60 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -73,7 +73,6 @@
       return false;
     }
 
-    ChangeNotes notes = notesFactory.createFromIndexedChange(change);
     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());
@@ -92,7 +91,7 @@
                     .filter(u -> u instanceof SingleGroupUser || u instanceof InternalUser)
                     .orElseGet(anonymousUserProvider::get));
     try {
-      withUser.indexedChange(cd, notes).check(ChangePermission.READ);
+      withUser.change(cd).check(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       Throwable cause = e.getCause();
       if (cause instanceof RepositoryNotFoundException) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index d762746..04e6d49 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -28,15 +28,16 @@
 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;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -48,7 +49,6 @@
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -117,12 +117,14 @@
    * <p>bind(ChangeHasOperandFactory.class) .annotatedWith(Exports.named("your has operand"))
    * .to(YourClass.class);
    */
-  private interface ChangeOperandFactory {
+  public interface ChangeOperandFactory {
     Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException;
   }
 
   public interface ChangeHasOperandFactory extends ChangeOperandFactory {}
 
+  public interface ChangeIsOperandFactory extends ChangeOperandFactory {}
+
   private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
   private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
   private static final Pattern DEF_CHANGE =
@@ -218,6 +220,7 @@
     final CommentsUtil commentsUtil;
     final ConflictsCache conflictsCache;
     final DynamicMap<ChangeHasOperandFactory> hasOperands;
+    final DynamicMap<ChangeIsOperandFactory> isOperands;
     final DynamicMap<ChangeOperatorFactory> opFactories;
     final GitRepositoryManager repoManager;
     final GroupBackend groupBackend;
@@ -244,6 +247,7 @@
         ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
         DynamicMap<ChangeHasOperandFactory> hasOperands,
+        DynamicMap<ChangeIsOperandFactory> isOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         PermissionBackend permissionBackend,
@@ -273,6 +277,7 @@
           rewriter,
           opFactories,
           hasOperands,
+          isOperands,
           userFactory,
           self,
           permissionBackend,
@@ -304,6 +309,7 @@
         ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
         DynamicMap<ChangeHasOperandFactory> hasOperands,
+        DynamicMap<ChangeIsOperandFactory> isOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         PermissionBackend permissionBackend,
@@ -351,6 +357,7 @@
       this.starredChangesUtil = starredChangesUtil;
       this.accountCache = accountCache;
       this.hasOperands = hasOperands;
+      this.isOperands = isOperands;
       this.groupMembers = groupMembers;
       this.changeIsVisbleToPredicateFactory = changeIsVisbleToPredicateFactory;
       this.operatorAliasConfig = operatorAliasConfig;
@@ -364,6 +371,7 @@
           rewriter,
           opFactories,
           hasOperands,
+          isOperands,
           userFactory,
           Providers.of(otherUser),
           permissionBackend,
@@ -651,6 +659,14 @@
       throw new QueryParseException("'is:wip' operator is not supported by change index version");
     }
 
+    // for plugins the value will be operandName_pluginName
+    List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
+    if (names.size() == 2) {
+      ChangeIsOperandFactory op = args.isOperands.get(names.get(1), names.get(0));
+      if (op != null) {
+        return op.create(this);
+      }
+    }
     return status(value);
   }
 
@@ -1401,6 +1417,12 @@
   private Predicate<ChangeData> getAuthorOrCommitterFullTextPredicate(
       String who, Function<String, Predicate<ChangeData>> fullPredicateFunc)
       throws QueryParseException {
+    if (isSelf(who)) {
+      IdentifiedUser me = args.getIdentifiedUser();
+      List<Predicate<ChangeData>> predicates =
+          me.getEmailAddresses().stream().map(fullPredicateFunc).collect(toList());
+      return Predicate.or(predicates);
+    }
     Set<String> parts = SchemaUtil.getNameParts(who);
     if (parts.isEmpty()) {
       throw error("invalid value");
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 40c0477..9677321 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.Extension;
@@ -33,15 +34,21 @@
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
+import com.google.gerrit.server.change.PluginDefinedInfosFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -52,11 +59,14 @@
  * holding on to a single instance.
  */
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
-    implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider {
+    implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider, PluginDefinedInfosFactory {
   private final Provider<CurrentUser> userProvider;
   private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
+  private final List<Extension<ChangePluginDefinedInfoFactory>>
+      changePluginDefinedInfoFactoriesByPlugin = new ArrayList<>();
+  private final Sequences sequences;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -74,7 +84,9 @@
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
       DynamicSet<ChangeAttributeFactory> attributeFactories,
-      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory) {
+      Sequences sequences,
+      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
+      DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
     super(
         metricMaker,
         ChangeSchemaDefinitions.INSTANCE,
@@ -85,13 +97,19 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.userProvider = userProvider;
     this.changeIsVisibleToPredicateFactory = changeIsVisibleToPredicateFactory;
+    this.sequences = sequences;
 
     ImmutableListMultimap.Builder<String, ChangeAttributeFactory> factoriesBuilder =
         ImmutableListMultimap.builder();
+    ImmutableListMultimap.Builder<String, ChangePluginDefinedInfoFactory> infosFactoriesBuilder =
+        ImmutableListMultimap.builder();
     // Eagerly call Extension#get() rather than storing Extensions, since that method invokes the
     // Provider on every call, which could be expensive if we invoke it once for every change.
     attributeFactories.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
     attributeFactoriesByPlugin = factoriesBuilder.build();
+    changePluginDefinedInfoFactories
+        .entries()
+        .forEach(e -> changePluginDefinedInfoFactoriesByPlugin.add(e));
   }
 
   @Override
@@ -128,6 +146,17 @@
             .map(e -> new Extension<>(e.getKey(), e::getValue)));
   }
 
+  public PluginDefinedInfosFactory getInfosFactory() {
+    return this::createPluginDefinedInfos;
+  }
+
+  @Override
+  public ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds) {
+    return PluginDefinedAttributesFactories.createAll(
+        cds, this, changePluginDefinedInfoFactoriesByPlugin.stream());
+  }
+
   @Override
   protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
     return new AndChangeSource(
@@ -138,4 +167,14 @@
   protected String formatForLogging(ChangeData changeData) {
     return changeData.getId().toString();
   }
+
+  @Override
+  protected int getIndexSize() {
+    return sequences.lastChangeId();
+  }
+
+  @Override
+  protected int getBatchSize() {
+    return sequences.changeBatchSize();
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index bd7981c..b8cf100 100644
--- a/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.Objects;
 
@@ -39,7 +39,7 @@
         return true;
       }
     }
-    for (Comment c : cd.publishedComments()) {
+    for (HumanComment c : cd.publishedComments()) {
       if (Objects.equals(c.author.getId(), id)) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/query/change/ConflictsCache.java b/java/com/google/gerrit/server/query/change/ConflictsCache.java
index c7ee79b..1e7ba93 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsCache.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsCache.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.common.Nullable;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 
 public interface ConflictsCache {
 
   void put(ConflictKey key, boolean value);
 
-  @Nullable
-  Boolean getIfPresent(ConflictKey key);
+  Boolean get(ConflictKey key, Callable<? extends Boolean> loader) throws ExecutionException;
 }
diff --git a/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java b/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
index 426c5d6..4926314 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
@@ -21,6 +21,8 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 
 @Singleton
 public class ConflictsCacheImpl implements ConflictsCache {
@@ -53,7 +55,8 @@
   }
 
   @Override
-  public Boolean getIfPresent(ConflictKey key) {
-    return conflictsCache.getIfPresent(key);
+  public Boolean get(ConflictKey key, Callable<? extends Boolean> loader)
+      throws ExecutionException {
+    return conflictsCache.get(key, loader);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 6eb6871d..f4af4ca 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -20,17 +20,17 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -41,6 +41,8 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -142,27 +144,8 @@
                 other,
                 str.type,
                 projectState.is(BooleanProjectConfig.USE_CONTENT_MERGE));
-        Boolean maybeConflicts = args.conflictsCache.getIfPresent(conflictsKey);
-        if (maybeConflicts != null) {
-          return maybeConflicts;
-        }
-
-        try (Repository repo = args.repoManager.openRepository(otherChange.getProject());
-            CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-          boolean conflicts =
-              !args.submitDryRun.run(
-                  null,
-                  str.type,
-                  repo,
-                  rw,
-                  otherChange.getDest(),
-                  changeDataCache.getTestAgainst(),
-                  other,
-                  getAlreadyAccepted(repo, rw));
-          args.conflictsCache.put(conflictsKey, conflicts);
-          return conflicts;
-        }
-      } catch (NoSuchProjectException | StorageException | IOException e) {
+        return args.conflictsCache.get(conflictsKey, new Loader(object, changeDataCache, args));
+      } catch (StorageException | ExecutionException | UncheckedExecutionException e) {
         ObjectId finalOther = other;
         warnWithOccasionalStackTrace(
             e,
@@ -179,23 +162,9 @@
     public int getCost() {
       return 5;
     }
-
-    private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw) {
-      try {
-        Set<RevCommit> accepted = new HashSet<>();
-        SubmitDryRun.addCommits(changeDataCache.getAlreadyAccepted(repo), rw, accepted);
-        ObjectId tip = changeDataCache.getTestAgainst();
-        if (tip != null) {
-          accepted.add(rw.parseCommit(tip));
-        }
-        return accepted;
-      } catch (StorageException | IOException e) {
-        throw new StorageException("Failed to determine already accepted commits.", e);
-      }
-    }
   }
 
-  private static class ChangeDataCache {
+  static class ChangeDataCache {
     private final ChangeData cd;
     private final ProjectCache projectCache;
 
@@ -238,4 +207,60 @@
         .atMostEvery(1, MINUTES)
         .logVarargs("(Re-logging with stack trace) " + format, args);
   }
+
+  private static class Loader implements Callable<Boolean> {
+    private final ChangeData changeData;
+    private final ConflictsPredicate.ChangeDataCache changeDataCache;
+    private final ChangeQueryBuilder.Arguments args;
+
+    private Loader(
+        ChangeData changeData,
+        ConflictsPredicate.ChangeDataCache changeDataCache,
+        ChangeQueryBuilder.Arguments args) {
+      this.changeData = changeData;
+      this.changeDataCache = changeDataCache;
+      this.args = args;
+    }
+
+    @Override
+    public Boolean call() throws Exception {
+      Change otherChange = changeData.change();
+      ObjectId other = changeData.currentPatchSet().commitId();
+      try (Repository repo = args.repoManager.openRepository(otherChange.getProject());
+          CodeReviewCommit.CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
+        return !args.submitDryRun.run(
+            null,
+            changeData.submitTypeRecord().type,
+            repo,
+            rw,
+            otherChange.getDest(),
+            changeDataCache.getTestAgainst(),
+            other,
+            getAlreadyAccepted(repo, rw));
+      } catch (NoSuchProjectException | IOException e) {
+        warnWithOccasionalStackTrace(
+            e,
+            "Failure when loading conflicts of change %s in %s (%s): %s",
+            lazy(changeData::getId),
+            lazy(() -> firstNonNull(otherChange.getProject(), "unknown project")),
+            lazy(() -> other != null ? other.name() : "unknown commit"),
+            e.getMessage());
+        return false;
+      }
+    }
+
+    private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw) {
+      try {
+        Set<RevCommit> accepted = new HashSet<>();
+        SubmitDryRun.addCommits(changeDataCache.getAlreadyAccepted(repo), rw, accepted);
+        ObjectId tip = changeDataCache.getTestAgainst();
+        if (tip != null) {
+          accepted.add(rw.parseCommit(tip));
+        }
+        return accepted;
+      } catch (StorageException | IOException e) {
+        throw new StorageException("Failed to determine already accepted commits.", e);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 160e9f9..4b46888 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.IdentifiedUser;
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6605c23..1012f4a 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -193,6 +193,7 @@
 
     List<ChangeNotes> notes =
         notesFactory.create(
+            repo,
             branch.project(),
             changeIds,
             cn -> {
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index e875499..6907f15 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -17,14 +17,19 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.PatchSetAttribute;
@@ -97,6 +102,8 @@
 
   private OutputStream outputStream = DisabledOutputStream.INSTANCE;
   private PrintWriter out;
+  private ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
+      ImmutableListMultimap.of();
 
   @Inject
   OutputStreamQuery(
@@ -200,13 +207,14 @@
         return;
       }
 
-      try {
+      try (PerThreadCache ignored = PerThreadCache.create()) {
         final QueryStatsAttribute stats = new QueryStatsAttribute();
         stats.runTimeMilliseconds = TimeUtil.nowMs();
 
         Map<Project.NameKey, Repository> repos = new HashMap<>();
         Map<Project.NameKey, RevWalk> revWalks = new HashMap<>();
         QueryResult<ChangeData> results = queryProcessor.query(queryBuilder.parse(queryString));
+        pluginInfosByChange = queryProcessor.createPluginDefinedInfos(results.entities());
         try {
           for (ChangeData d : results.entities()) {
             show(buildChangeAttribute(d, repos, revWalks));
@@ -244,7 +252,8 @@
       ChangeData d, Map<Project.NameKey, Repository> repos, Map<Project.NameKey, RevWalk> revWalks)
       throws IOException {
     LabelTypes labelTypes = d.getLabelTypes();
-    ChangeAttribute c = eventFactory.asChangeAttribute(d.change(), d.notes());
+    ChangeAttribute c = eventFactory.asChangeAttribute(d.change());
+    c.hashtags = Lists.newArrayList(d.hashtags());
     eventFactory.extend(c, d.change());
 
     if (!trackingFooters.isEmpty()) {
@@ -325,6 +334,15 @@
     }
 
     c.plugins = queryProcessor.getAttributesFactory().create(d);
+    List<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
+    if (!pluginInfos.isEmpty()) {
+      if (c.plugins == null) {
+        c.plugins = pluginInfos;
+      } else {
+        c.plugins = new ArrayList<>(c.plugins);
+        c.plugins.addAll(pluginInfos);
+      }
+    }
     return c;
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
index 070f800..62fe9e8 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index bc65422..4ca684a 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -16,8 +16,8 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.Set;
diff --git a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index c507f1c..2018fbc 100644
--- a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class SubmittablePredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index 4e60db5..fbc8d0e 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -19,9 +19,9 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index 86c574d..ed687f3 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexRewriter;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -42,6 +43,7 @@
 public class GroupQueryProcessor extends QueryProcessor<InternalGroup> {
   private final Provider<CurrentUser> userProvider;
   private final GroupControl.GenericFactory groupControlFactory;
+  private final Sequences sequences;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -58,7 +60,8 @@
       IndexConfig indexConfig,
       GroupIndexCollection indexes,
       GroupIndexRewriter rewriter,
-      GroupControl.GenericFactory groupControlFactory) {
+      GroupControl.GenericFactory groupControlFactory,
+      Sequences sequences) {
     super(
         metricMaker,
         GroupSchemaDefinitions.INSTANCE,
@@ -69,6 +72,7 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.userProvider = userProvider;
     this.groupControlFactory = groupControlFactory;
+    this.sequences = sequences;
   }
 
   @Override
@@ -81,4 +85,14 @@
   protected String formatForLogging(InternalGroup internalGroup) {
     return internalGroup.getGroupUUID().get();
   }
+
+  @Override
+  protected int getIndexSize() {
+    return sequences.lastGroupId();
+  }
+
+  @Override
+  protected int getBatchSize() {
+    return sequences.groupBatchSize();
+  }
 }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
index 66eab7b..11783fc 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -42,6 +43,7 @@
 public class ProjectQueryProcessor extends QueryProcessor<ProjectData> {
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> userProvider;
+  private final ProjectCache projectCache;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -58,7 +60,8 @@
       IndexConfig indexConfig,
       ProjectIndexCollection indexes,
       ProjectIndexRewriter rewriter,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache) {
     super(
         metricMaker,
         ProjectSchemaDefinitions.INSTANCE,
@@ -69,6 +72,7 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.permissionBackend = permissionBackend;
     this.userProvider = userProvider;
+    this.projectCache = projectCache;
   }
 
   @Override
@@ -81,4 +85,14 @@
   protected String formatForLogging(ProjectData projectData) {
     return projectData.getProject().getName();
   }
+
+  @Override
+  protected int getIndexSize() {
+    return projectCache.all().size();
+  }
+
+  @Override
+  protected int getBatchSize() {
+    return 1;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 3f28a03..4c07d8a 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -8,6 +8,7 @@
     name = "restapi",
     srcs = glob(["**/*.java"]),
     deps = [
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
@@ -31,6 +32,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:servlet-api",
+        "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:compress",
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index 26333b4..b65f4ee 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -21,9 +21,9 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index fee5eab..6ee4539 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -80,6 +81,7 @@
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
+  private final MessageIdGenerator messageIdGenerator;
   private final boolean isDevMode;
 
   @Inject
@@ -91,7 +93,8 @@
       AccountManager accountManager,
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
-      OutgoingEmailValidator validator) {
+      OutgoingEmailValidator validator,
+      MessageIdGenerator messageIdGenerator) {
     this.self = self;
     this.realm = realm;
     this.permissionBackend = permissionBackend;
@@ -100,6 +103,7 @@
     this.putPreferred = putPreferred;
     this.validator = validator;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -157,11 +161,12 @@
       }
     } else {
       try {
-        RegisterNewEmailSender sender = registerNewEmailFactory.create(email);
-        if (!sender.isAllowed()) {
+        RegisterNewEmailSender emailSender = registerNewEmailFactory.create(email);
+        if (!emailSender.isAllowed()) {
           throw new MethodNotAllowedException("Not allowed to add email address " + email);
         }
-        sender.send();
+        emailSender.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+        emailSender.send();
         info.pendingConfirmation = true;
       } catch (EmailException | RuntimeException e) {
         logger.atSevere().withCause(e).log("Cannot send email verification message to %s", email);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index 4b505c6..ec82e1a 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -40,18 +39,15 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.HasDraftByPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.CommentJson;
-import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
+import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdate.Factory;
-import com.google.gerrit.server.update.BatchUpdateListener;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
@@ -80,7 +76,6 @@
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComments(
@@ -92,8 +87,7 @@
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache) {
+      PatchSetUtil psUtil) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
     this.queryBuilderProvider = queryBuilderProvider;
@@ -103,7 +97,6 @@
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -123,7 +116,8 @@
       throw new AuthException("Cannot delete drafts of other user");
     }
 
-    CommentFormatter commentFormatter = commentJsonProvider.get().newCommentFormatter();
+    HumanCommentFormatter humanCommentFormatter =
+        commentJsonProvider.get().newHumanCommentFormatter();
     Account.Id accountId = rsrc.getUser().getAccountId();
     Timestamp now = TimeUtil.nowTs();
     Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
@@ -137,7 +131,7 @@
       BatchUpdate update =
           updates.computeIfAbsent(
               cd.project(), p -> batchUpdateFactory.create(p, rsrc.getUser(), now));
-      Op op = new Op(commentFormatter, accountId);
+      Op op = new Op(humanCommentFormatter, accountId);
       update.addOp(cd.getId(), op);
       ops.add(op);
     }
@@ -145,7 +139,7 @@
     // Currently there's no way to let some updates succeed even if others fail. Even if there were,
     // all updates from this operation only happen in All-Users and thus are fully atomic, so
     // allowing partial failure would have little value.
-    BatchUpdate.execute(updates.values(), BatchUpdateListener.NONE, false);
+    BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
 
     return Response.ok(
         ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList()));
@@ -165,26 +159,25 @@
   }
 
   private class Op implements BatchUpdateOp {
-    private final CommentFormatter commentFormatter;
+    private final HumanCommentFormatter humanCommentFormatter;
     private final Account.Id accountId;
     private DeletedDraftCommentInfo result;
 
-    Op(CommentFormatter commentFormatter, Account.Id accountId) {
-      this.commentFormatter = commentFormatter;
+    Op(HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
+      this.humanCommentFormatter = humanCommentFormatter;
       this.accountId = accountId;
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws PatchListNotAvailableException, PermissionBackendException {
+    public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
       ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
       boolean dirty = false;
-      for (Comment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
+      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
         dirty = true;
         PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
-        setCommentCommitId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
-        commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
-        comments.add(commentFormatter.format(c));
+        commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+        commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
+        comments.add(humanCommentFormatter.format(c));
       }
       if (dirty) {
         result = new DeletedDraftCommentInfo();
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index aeaeb1c..db6ad48 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -96,7 +96,7 @@
 
     List<AgreementInfo> results = new ArrayList<>();
     Collection<ContributorAgreement> cas =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
+        projectCache.getAllProjects().getConfig().getContributorAgreements().values();
     for (ContributorAgreement ca : cas) {
       List<AccountGroup.UUID> groupIds = new ArrayList<>();
       for (PermissionRule rule : ca.getAccepted()) {
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index f3d9557..6ab2c44 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -21,7 +21,7 @@
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index beb5e8f..8d65aac 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index b2859e6..17e31bd 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -14,17 +14,20 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 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.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryParser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.ProjectWatches;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -95,6 +98,17 @@
         throw new BadRequestException("project name must be specified");
       }
 
+      if (!Strings.isNullOrEmpty(info.filter)) {
+        try {
+          QueryParser.parse(info.filter);
+        } catch (QueryParseException e) {
+          throw new BadRequestException(
+              String.format(
+                  "invalid filter expression for project %s: %s", info.project, e.getMessage()),
+              e);
+        }
+      }
+
       ProjectWatchKey key =
           ProjectWatchKey.create(projectsCollection.parse(info.project).getNameKey(), info.filter);
       if (m.containsKey(key)) {
diff --git a/java/com/google/gerrit/server/restapi/account/PutAgreement.java b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
index 42504a0..47c223c 100644
--- a/java/com/google/gerrit/server/restapi/account/PutAgreement.java
+++ b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
@@ -16,8 +16,8 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.accounts.AgreementInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -82,7 +82,7 @@
 
     String agreementName = Strings.nullToEmpty(input.name);
     ContributorAgreement ca =
-        projectCache.getAllProjects().getConfig().getContributorAgreement(agreementName);
+        projectCache.getAllProjects().getConfig().getContributorAgreements().get(agreementName);
     if (ca == null) {
       throw new UnprocessableEntityException("contributor agreement not found");
     }
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index 5176fe9..a5be14f 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -14,9 +14,9 @@
 
 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.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -24,12 +24,15 @@
 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.account.ServiceUserClassifier;
 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.change.NotifyResolver;
 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.AttentionSetUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -38,12 +41,14 @@
 @Singleton
 public class AddToAttentionSet
     implements RestCollectionModifyView<
-        ChangeResource, AttentionSetEntryResource, AddToAttentionSetInput> {
+        ChangeResource, AttentionSetEntryResource, AttentionSetInput> {
   private final BatchUpdate.Factory updateFactory;
   private final AccountResolver accountResolver;
   private final AddToAttentionSetOp.Factory opFactory;
   private final AccountLoader.Factory accountLoaderFactory;
   private final PermissionBackend permissionBackend;
+  private final NotifyResolver notifyResolver;
+  private final ServiceUserClassifier serviceUserClassifier;
 
   @Inject
   AddToAttentionSet(
@@ -51,27 +56,29 @@
       AccountResolver accountResolver,
       AddToAttentionSetOp.Factory opFactory,
       AccountLoader.Factory accountLoaderFactory,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      NotifyResolver notifyResolver,
+      ServiceUserClassifier serviceUserClassifier) {
     this.updateFactory = updateFactory;
     this.accountResolver = accountResolver;
     this.opFactory = opFactory;
     this.accountLoaderFactory = accountLoaderFactory;
     this.permissionBackend = permissionBackend;
+    this.notifyResolver = notifyResolver;
+    this.serviceUserClassifier = serviceUserClassifier;
   }
 
   @Override
-  public Response<AccountInfo> apply(ChangeResource changeResource, AddToAttentionSetInput input)
+  public Response<AccountInfo> apply(ChangeResource changeResource, AttentionSetInput 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");
-    }
+    AttentionSetUtil.validateInput(input);
 
     Account.Id attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
+    if (serviceUserClassifier.isServiceUser(attentionUserId)) {
+      throw new BadRequestException(
+          String.format(
+              "%s is a robot, and robots can't be added to the attention set.", input.user));
+    }
     try {
       permissionBackend
           .absentUser(attentionUserId)
@@ -84,8 +91,11 @@
     try (BatchUpdate bu =
         updateFactory.create(
             changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
-      AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason);
+      AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
       bu.addOp(changeResource.getId(), op);
+      NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+      NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+      bu.setNotify(notifyResult);
       bu.execute();
       return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
index 40e4fc2..9224154 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditJson;
 import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.edit.CommitModification;
 import com.google.gerrit.server.fixes.FixReplacementInterpreter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -40,7 +40,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
@@ -76,12 +75,12 @@
     PatchSet patchSet = revisionResource.getPatchSet();
 
     try (Repository repository = gitRepositoryManager.openRepository(project)) {
-      List<TreeModification> treeModifications =
-          fixReplacementInterpreter.toTreeModifications(
+      CommitModification commitModification =
+          fixReplacementInterpreter.toCommitModification(
               repository, projectState, patchSet.commitId(), fixResource.getFixReplacements());
       ChangeEdit changeEdit =
           changeEditModifier.combineWithModifiedPatchSetTree(
-              repository, revisionResource.getNotes(), patchSet, treeModifications);
+              repository, revisionResource.getNotes(), patchSet, commitModification);
       return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
     } catch (InvalidChangeOperationException e) {
       throw new ResourceConflictException(e.getMessage());
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index d510fce..7a15a1d 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -56,7 +56,6 @@
 import com.google.gerrit.server.edit.ChangeEditJson;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.edit.UnchangedCommitMessageException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -481,8 +480,8 @@
 
       Project.NameKey project = rsrc.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.modifyMessage(repository, project, rsrc.getNotes(), input.message);
-      } catch (UnchangedCommitMessageException e) {
+        editModifier.modifyMessage(repository, rsrc.getNotes(), input.message);
+      } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 44dc6e1..6ee3284c 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -20,7 +20,6 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
@@ -29,6 +28,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -42,7 +42,9 @@
 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.ResetCherryPickOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -112,6 +114,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final NotifyResolver notifyResolver;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   CherryPickChange(
@@ -128,7 +131,8 @@
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
       NotifyResolver notifyResolver,
-      BatchUpdate.Factory batchUpdateFactory) {
+      BatchUpdate.Factory batchUpdateFactory,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
@@ -143,6 +147,7 @@
     this.approvalsUtil = approvalsUtil;
     this.notifyResolver = notifyResolver;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.urlFormatter = urlFormatter;
   }
 
   /**
@@ -173,6 +178,7 @@
         TimeUtil.nowTs(),
         null,
         null,
+        null,
         null);
   }
 
@@ -204,7 +210,7 @@
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null);
+        sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null, null);
   }
 
   /**
@@ -245,7 +251,8 @@
       Timestamp timestamp,
       @Nullable Change.Id revertedChange,
       @Nullable ObjectId changeIdForNewChange,
-      @Nullable Change.Id idForNewChange)
+      @Nullable Change.Id idForNewChange,
+      @Nullable Boolean workInProgress)
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
 
@@ -312,7 +319,8 @@
                 input.allowConflicts);
 
         Change.Key changeKey;
-        final List<String> idList = cherryPickCommit.getFooterLines(FooterConstants.CHANGE_ID);
+        final List<String> idList =
+            ChangeUtil.getChangeIdsFromFooter(cherryPickCommit, urlFormatter.get());
         if (!idList.isEmpty()) {
           final String idStr = idList.get(idList.size() - 1).trim();
           changeKey = Change.key(idStr);
@@ -364,8 +372,9 @@
                     git,
                     destChanges.get(0).notes(),
                     cherryPickCommit,
-                    sourceChange.currentPatchSetId(),
-                    newTopic);
+                    sourceChange,
+                    newTopic,
+                    workInProgress);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
@@ -380,7 +389,8 @@
                     sourceCommit,
                     input,
                     revertedChange,
-                    idForNewChange);
+                    idForNewChange,
+                    workInProgress);
           }
           bu.execute();
           return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
@@ -447,16 +457,24 @@
       Repository git,
       ChangeNotes destNotes,
       CodeReviewCommit cherryPickCommit,
-      PatchSet.Id sourcePatchSetId,
-      String topic)
+      @Nullable Change sourceChange,
+      String topic,
+      @Nullable Boolean workInProgress)
       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);
+    if (workInProgress != null) {
+      inserter.setWorkInProgress(workInProgress);
+    }
     bu.addOp(destChange.getId(), inserter);
-    if (destChange.getCherryPickOf() == null
+    PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
+    // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
+    if (sourcePatchSetId == null) {
+      bu.addOp(destChange.getId(), new ResetCherryPickOp());
+    } else if (destChange.getCherryPickOf() == null
         || !destChange.getCherryPickOf().equals(sourcePatchSetId)) {
       SetCherryPickOp cherryPickOfUpdater = setCherryPickOfFactory.create(sourcePatchSetId);
       bu.addOp(destChange.getId(), cherryPickOfUpdater);
@@ -474,11 +492,19 @@
       @Nullable ObjectId sourceCommit,
       CherryPickInput input,
       @Nullable Change.Id revertOf,
-      @Nullable Change.Id idForNewChange)
+      @Nullable Change.Id idForNewChange,
+      @Nullable Boolean workInProgress)
       throws IOException, InvalidChangeOperationException {
     Change.Id changeId = idForNewChange != null ? idForNewChange : Change.id(seq.nextChangeId());
     ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
     ins.setRevertOf(revertOf);
+    if (workInProgress != null) {
+      ins.setWorkInProgress(workInProgress);
+    } else {
+      ins.setWorkInProgress(
+          (sourceChange != null && sourceChange.isWorkInProgress())
+              || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
+    }
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
@@ -488,10 +514,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());
+        .setCherryPickOf(sourcePatchSetId);
     if (input.keepReviewers && sourceChange != null) {
       ReviewerSet reviewerSet =
           approvalsUtil.getReviewers(changeNotesFactory.createChecked(sourceChange));
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 03898b1..4de9b63 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
@@ -33,6 +35,7 @@
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
@@ -47,10 +50,15 @@
 
   private boolean fillAccounts = true;
   private boolean fillPatchSet;
+  private CommentContextLoader.Factory commentContextLoaderFactory;
+  private CommentContextLoader commentContextLoader;
 
   @Inject
-  CommentJson(AccountLoader.Factory accountLoaderFactory) {
+  CommentJson(
+      AccountLoader.Factory accountLoaderFactory,
+      CommentContextLoader.Factory commentContextLoaderFactory) {
     this.accountLoaderFactory = accountLoaderFactory;
+    this.commentContextLoaderFactory = commentContextLoaderFactory;
   }
 
   CommentJson setFillAccounts(boolean fillAccounts) {
@@ -63,8 +71,15 @@
     return this;
   }
 
-  public CommentFormatter newCommentFormatter() {
-    return new CommentFormatter();
+  CommentJson setEnableContext(boolean enableContext, Project.NameKey project) {
+    if (enableContext) {
+      this.commentContextLoader = commentContextLoaderFactory.create(project);
+    }
+    return this;
+  }
+
+  public HumanCommentFormatter newHumanCommentFormatter() {
+    return new HumanCommentFormatter();
   }
 
   public RobotCommentFormatter newRobotCommentFormatter() {
@@ -78,6 +93,9 @@
       if (loader != null) {
         loader.fill();
       }
+      if (commentContextLoader != null) {
+        commentContextLoader.fill();
+      }
       return info;
     }
 
@@ -102,6 +120,9 @@
       if (loader != null) {
         loader.fill();
       }
+      if (commentContextLoader != null) {
+        commentContextLoader.fill();
+      }
       return out;
     }
 
@@ -117,6 +138,9 @@
       if (loader != null) {
         loader.fill();
       }
+      if (commentContextLoader != null) {
+        commentContextLoader.fill();
+      }
       return out;
     }
 
@@ -142,10 +166,13 @@
       r.updated = c.writtenOn;
       r.range = toRange(c.range);
       r.tag = c.tag;
-      r.unresolved = c.unresolved;
       if (loader != null) {
         r.author = loader.get(c.author.getId());
       }
+      r.commitId = c.getCommitId().getName();
+      if (commentContextLoader != null) {
+        r.contextLines = commentContextLoader.getContext(r);
+      }
     }
 
     protected Range toRange(Comment.Range commentRange) {
@@ -161,15 +188,16 @@
     }
   }
 
-  public class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
+  public class HumanCommentFormatter extends BaseCommentFormatter<HumanComment, CommentInfo> {
     @Override
-    protected CommentInfo toInfo(Comment c, AccountLoader loader) {
+    protected CommentInfo toInfo(HumanComment c, AccountLoader loader) {
       CommentInfo ci = new CommentInfo();
       fillCommentInfo(c, ci, loader);
+      ci.unresolved = c.unresolved;
       return ci;
     }
 
-    private CommentFormatter() {}
+    private HumanCommentFormatter() {}
   }
 
   class RobotCommentFormatter extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
new file mode 100644
index 0000000..681509c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -0,0 +1,335 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.HumanComment;
+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.Whitespace;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.DiffMappings;
+import com.google.gerrit.server.patch.GitPositionTransformer;
+import com.google.gerrit.server.patch.GitPositionTransformer.BestPositionOnConflict;
+import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.Position;
+import com.google.gerrit.server.patch.GitPositionTransformer.PositionedEntity;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Container for all logic necessary to port comments to a target patchset.
+ *
+ * <p>A ported comment is a comment which was left on an earlier patchset and is shown on a later
+ * patchset. If a comment eligible for porting (e.g. before target patchset) can't be matched to its
+ * exact position in the target patchset, we'll map it to its next best location. This can also
+ * include a transformation of a line comment into a file comment.
+ */
+@Singleton
+public class CommentPorter {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitPositionTransformer positionTransformer =
+      new GitPositionTransformer(BestPositionOnConflict.INSTANCE);
+  private final PatchListCache patchListCache;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil) {
+    this.patchListCache = patchListCache;
+    this.commentsUtil = commentsUtil;
+  }
+
+  /**
+   * Ports the given comments to the target patchset.
+   *
+   * <p>Not all given comments are ported. Only those fulfilling some criteria (e.g. before target
+   * patchset) are considered eligible for porting.
+   *
+   * <p>The returned comments represent the ported version. They don't bear any indication to which
+   * patchset they were ported. This is intentional as the target patchset should be obvious from
+   * the API or the used REST resources. The returned comments still have the patchset field filled.
+   * It contains the reference to the patchset on which the comment was originally left. That
+   * patchset number can vary among the returned comments as all comments before the target patchset
+   * are potentially eligible for porting.
+   *
+   * <p>The number of returned comments can be smaller (-> only eligible ones are ported!) or larger
+   * compared to the provided comments. The latter happens when files appear as copied in the target
+   * patchset. In such a situation, the same comment UUID will occur more than once in the returned
+   * comments.
+   *
+   * @param changeNotes the {@link ChangeNotes} of the change to which the comments belong
+   * @param targetPatchset the patchset to which the comments should be ported
+   * @param comments the original comments
+   * @param filters additional filters to apply to the comments before porting. Only the remaining
+   *     comments will be ported.
+   * @return the ported comments, in no particular order
+   */
+  public ImmutableList<HumanComment> portComments(
+      ChangeNotes changeNotes,
+      PatchSet targetPatchset,
+      List<HumanComment> comments,
+      List<HumanCommentFilter> filters) {
+
+    ImmutableList<HumanCommentFilter> allFilters = addDefaultFilters(filters, targetPatchset);
+    ImmutableList<HumanComment> relevantComments = filter(comments, allFilters);
+    return port(changeNotes, targetPatchset, relevantComments);
+  }
+
+  private ImmutableList<HumanCommentFilter> addDefaultFilters(
+      List<HumanCommentFilter> filters, PatchSet targetPatchset) {
+    // Apply the EarlierPatchsetCommentFilter first as it reduces the amount of comments before
+    // more expensive filters are applied.
+    HumanCommentFilter earlierPatchsetFilter =
+        new EarlierPatchsetCommentFilter(targetPatchset.id());
+    return Stream.concat(Stream.of(earlierPatchsetFilter), filters.stream())
+        .collect(toImmutableList());
+  }
+
+  private ImmutableList<HumanComment> filter(
+      List<HumanComment> allComments, ImmutableList<HumanCommentFilter> filters) {
+    ImmutableList<HumanComment> filteredComments = ImmutableList.copyOf(allComments);
+    for (HumanCommentFilter filter : filters) {
+      filteredComments = filter.filter(filteredComments);
+    }
+    return filteredComments;
+  }
+
+  private ImmutableList<HumanComment> port(
+      ChangeNotes notes, PatchSet targetPatchset, List<HumanComment> comments) {
+    Map<Integer, ImmutableList<HumanComment>> commentsPerPatchset =
+        comments.stream().collect(groupingBy(comment -> comment.key.patchSetId, toImmutableList()));
+
+    ImmutableList.Builder<HumanComment> portedComments =
+        ImmutableList.builderWithExpectedSize(comments.size());
+    for (Integer originalPatchsetId : commentsPerPatchset.keySet()) {
+      ImmutableList<HumanComment> patchsetComments = commentsPerPatchset.get(originalPatchsetId);
+      PatchSet originalPatchset =
+          notes.getPatchSets().get(PatchSet.id(notes.getChangeId(), originalPatchsetId));
+      if (originalPatchset != null) {
+        portedComments.addAll(
+            portSamePatchset(
+                notes.getProjectName(),
+                notes.getChange(),
+                originalPatchset,
+                targetPatchset,
+                patchsetComments));
+      } else {
+        logger.atWarning().log(
+            String.format(
+                "Some comments which should be ported refer to the non-existent patchset %s of"
+                    + " change %d. Omitting %d affected comments.",
+                originalPatchsetId, notes.getChangeId().get(), patchsetComments.size()));
+      }
+    }
+    return portedComments.build();
+  }
+
+  private ImmutableList<HumanComment> portSamePatchset(
+      Project.NameKey project,
+      Change change,
+      PatchSet originalPatchset,
+      PatchSet targetPatchset,
+      ImmutableList<HumanComment> comments) {
+    Map<Short, List<HumanComment>> commentsPerSide =
+        comments.stream().collect(groupingBy(comment -> comment.side));
+    ImmutableList.Builder<HumanComment> portedComments = ImmutableList.builder();
+    for (Entry<Short, List<HumanComment>> sideAndComments : commentsPerSide.entrySet()) {
+      portedComments.addAll(
+          portSamePatchsetAndSide(
+              project,
+              change,
+              originalPatchset,
+              targetPatchset,
+              sideAndComments.getValue(),
+              sideAndComments.getKey()));
+    }
+    return portedComments.build();
+  }
+
+  private ImmutableList<HumanComment> portSamePatchsetAndSide(
+      Project.NameKey project,
+      Change change,
+      PatchSet originalPatchset,
+      PatchSet targetPatchset,
+      List<HumanComment> comments,
+      short side) {
+    ImmutableSet<Mapping> mappings;
+    try {
+      mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
+    } catch (Exception e) {
+      logger.atWarning().withCause(e).log(
+          "Could not determine some necessary diff mappings for porting comments on change %s from"
+              + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
+              + " destination.",
+          change.getChangeId(),
+          originalPatchset.id().getId(),
+          targetPatchset.id().getId(),
+          comments.size());
+      mappings = getFallbackMappings(comments);
+    }
+
+    ImmutableList<PositionedEntity<HumanComment>> positionedComments =
+        comments.stream().map(this::toPositionedEntity).collect(toImmutableList());
+    return positionTransformer.transform(positionedComments, mappings).stream()
+        .map(PositionedEntity::getEntityAtUpdatedPosition)
+        .collect(toImmutableList());
+  }
+
+  private ImmutableSet<Mapping> loadMappings(
+      Project.NameKey project,
+      Change change,
+      PatchSet originalPatchset,
+      PatchSet targetPatchset,
+      short side)
+      throws PatchListNotAvailableException {
+    ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
+    ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
+    return loadCommitMappings(project, originalCommit, targetCommit);
+  }
+
+  private ObjectId determineCommitId(Change change, PatchSet patchset, short side) {
+    return commentsUtil
+        .determineCommitId(change, patchset, side)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Commit indicated by change %d, patchset %d, side %d doesn't exist.",
+                        change.getId().get(), patchset.id().get(), side)));
+  }
+
+  private ImmutableSet<Mapping> loadCommitMappings(
+      Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit)
+      throws PatchListNotAvailableException {
+    PatchList patchList =
+        patchListCache.get(
+            PatchListKey.againstCommit(originalCommit, targetCommit, Whitespace.IGNORE_NONE),
+            project);
+    return patchList.getPatches().stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+  }
+
+  private ImmutableSet<Mapping> getFallbackMappings(List<HumanComment> comments) {
+    // Consider all files as deleted. -> Comments will be ported to the fallback destination, which
+    // currently are patchset-level comments.
+    return comments.stream()
+        .map(comment -> comment.key.filename)
+        .distinct()
+        .map(FileMapping::forDeletedFile)
+        .map(fileMapping -> Mapping.create(fileMapping, ImmutableSet.of()))
+        .collect(toImmutableSet());
+  }
+
+  private PositionedEntity<HumanComment> toPositionedEntity(HumanComment comment) {
+    return PositionedEntity.create(
+        comment, CommentPorter::extractPosition, CommentPorter::createCommentAtNewPosition);
+  }
+
+  private static Position extractPosition(HumanComment comment) {
+    Position.Builder positionBuilder = Position.builder();
+    // Patchset-level comments don't have a file path. The transformation logic still works when
+    // using the magic file path but it doesn't hurt to use the actual representation for "no file"
+    // internally.
+    if (!Patch.PATCHSET_LEVEL.equals(comment.key.filename)) {
+      positionBuilder.filePath(comment.key.filename);
+    }
+    return positionBuilder.lineRange(extractLineRange(comment)).build();
+  }
+
+  private static Optional<GitPositionTransformer.Range> extractLineRange(HumanComment comment) {
+    // Line specifications in comment are 1-based. Line specifications in Position are 0-based.
+    if (comment.range != null) {
+      // The combination of (line, charOffset) is exclusive and must be mapped to an exclusive line.
+      int exclusiveEndLine =
+          comment.range.endChar > 0 ? comment.range.endLine : comment.range.endLine - 1;
+      return Optional.of(
+          GitPositionTransformer.Range.create(comment.range.startLine - 1, exclusiveEndLine));
+    }
+    if (comment.lineNbr > 0) {
+      return Optional.of(GitPositionTransformer.Range.create(comment.lineNbr - 1, comment.lineNbr));
+    }
+    // File comment -> no range.
+    return Optional.empty();
+  }
+
+  private static HumanComment createCommentAtNewPosition(
+      HumanComment originalComment, Position newPosition) {
+    HumanComment portedComment = new HumanComment(originalComment);
+    portedComment.key.filename = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL);
+    if (portedComment.range != null && newPosition.lineRange().isPresent()) {
+      // Comment was a range comment and also stayed one.
+      portedComment.range =
+          toRange(
+              newPosition.lineRange().get(),
+              portedComment.range.startChar,
+              portedComment.range.endChar);
+      portedComment.lineNbr = portedComment.range.endLine;
+    } else {
+      portedComment.range = null;
+      // No line -> use 0 = file comment or any other comment type without an explicit line.
+      portedComment.lineNbr = newPosition.lineRange().map(range -> range.start() + 1).orElse(0);
+    }
+    if (Patch.PATCHSET_LEVEL.equals(portedComment.key.filename)) {
+      // Correct the side of the comment to Side.REVISION (= 1) if the comment was changed to
+      // patchset level.
+      portedComment.side = 1;
+    }
+    return portedComment;
+  }
+
+  private static Range toRange(
+      GitPositionTransformer.Range lineRange, int originalStartChar, int originalEndChar) {
+    int adjustedEndLine = originalEndChar > 0 ? lineRange.end() : lineRange.end() + 1;
+    return new Range(lineRange.start() + 1, originalStartChar, adjustedEndLine, originalEndChar);
+  }
+
+  /** A filter which just keeps those comments which are before the given patchset. */
+  private static class EarlierPatchsetCommentFilter implements HumanCommentFilter {
+
+    private final PatchSet.Id patchsetId;
+
+    public EarlierPatchsetCommentFilter(PatchSet.Id patchsetId) {
+      this.patchsetId = patchsetId;
+    }
+
+    @Override
+    public ImmutableList<HumanComment> filter(ImmutableList<HumanComment> comments) {
+      return comments.stream()
+          .filter(comment -> comment.key.patchSetId < patchsetId.get())
+          .collect(toImmutableList());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Comments.java b/java/com/google/gerrit/server/restapi/change/Comments.java
index 078c239..00566f3 100644
--- a/java/com/google/gerrit/server/restapi/change/Comments.java
+++ b/java/com/google/gerrit/server/restapi/change/Comments.java
@@ -14,28 +14,28 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 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.RestView;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class Comments implements ChildCollection<RevisionResource, CommentResource> {
-  private final DynamicMap<RestView<CommentResource>> views;
+public class Comments implements ChildCollection<RevisionResource, HumanCommentResource> {
+  private final DynamicMap<RestView<HumanCommentResource>> views;
   private final ListRevisionComments list;
   private final CommentsUtil commentsUtil;
 
   @Inject
   Comments(
-      DynamicMap<RestView<CommentResource>> views,
+      DynamicMap<RestView<HumanCommentResource>> views,
       ListRevisionComments list,
       CommentsUtil commentsUtil) {
     this.views = views;
@@ -44,7 +44,7 @@
   }
 
   @Override
-  public DynamicMap<RestView<CommentResource>> views() {
+  public DynamicMap<RestView<HumanCommentResource>> views() {
     return views;
   }
 
@@ -54,13 +54,14 @@
   }
 
   @Override
-  public CommentResource parse(RevisionResource rev, IdString id) throws ResourceNotFoundException {
+  public HumanCommentResource parse(RevisionResource rev, IdString id)
+      throws ResourceNotFoundException {
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (Comment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().id())) {
+    for (HumanComment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().id())) {
       if (uuid.equals(c.key.uuid)) {
-        return new CommentResource(rev, c);
+        return new HumanCommentResource(rev, c);
       }
     }
     throw new ResourceNotFoundException(id);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 9e792d0..52887e0 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -176,16 +176,28 @@
   public Response<ChangeInfo> apply(TopLevelResource parent, ChangeInput input)
       throws IOException, InvalidChangeOperationException, RestApiException, UpdateException,
           PermissionBackendException, ConfigInvalidException {
+    if (Strings.isNullOrEmpty(input.project)) {
+      throw new BadRequestException("project must be non-empty");
+    }
+
+    return execute(updateFactory, input, projectsCollection.parse(input.project));
+  }
+
+  /** Creates the changes in the given project. This is public for reuse in the project API. */
+  public Response<ChangeInfo> execute(
+      BatchUpdate.Factory updateFactory, ChangeInput input, ProjectResource projectResource)
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          ConfigInvalidException {
     if (!user.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    IdentifiedUser me = user.get().asIdentifiedUser();
-    checkAndSanitizeChangeInput(input, me);
 
-    ProjectResource projectResource = projectsCollection.parse(input.project);
     ProjectState projectState = projectResource.getProjectState();
     projectState.checkStatePermitsWrite();
 
+    IdentifiedUser me = user.get().asIdentifiedUser();
+    checkAndSanitizeChangeInput(input, me);
+
     Project.NameKey project = projectResource.getNameKey();
     contributorAgreements.check(project, user.get());
 
@@ -207,10 +219,6 @@
    */
   private void checkAndSanitizeChangeInput(ChangeInput input, IdentifiedUser me)
       throws RestApiException, PermissionBackendException, IOException {
-    if (Strings.isNullOrEmpty(input.project)) {
-      throw new BadRequestException("project must be non-empty");
-    }
-
     if (Strings.isNullOrEmpty(input.branch)) {
       throw new BadRequestException("branch must be non-empty");
     }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 5b7245d..8476767 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -31,8 +31,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -50,20 +48,17 @@
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
 
   @Inject
   CreateDraftComment(
       BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache) {
+      PatchSetUtil psUtil) {
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -73,10 +68,18 @@
       throw new BadRequestException("path must be non-empty");
     } else if (in.message == null || in.message.trim().isEmpty()) {
       throw new BadRequestException("message must be non-empty");
+    } else if (in.path.equals(PATCHSET_LEVEL)
+        && (in.side != null || in.range != null || in.line != null)) {
+      throw new BadRequestException("patchset-level comments can't have side, range, or line");
     } else if (in.line != null && in.line < 0) {
       throw new BadRequestException("line must be >= 0");
     } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
       throw new BadRequestException("range endLine must be on the same line as the comment");
+    } else if (in.inReplyTo != null
+        && !commentsUtil.getPublishedHumanComment(rsrc.getNotes(), in.inReplyTo).isPresent()
+        && !commentsUtil.getRobotComment(rsrc.getNotes(), in.inReplyTo).isPresent()) {
+      throw new BadRequestException(
+          String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
 
     try (BatchUpdate bu =
@@ -85,7 +88,7 @@
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.created(
-          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
+          commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
     }
   }
 
@@ -93,7 +96,7 @@
     private final PatchSet.Id psId;
     private final DraftInput in;
 
-    private Comment comment;
+    private HumanComment comment;
 
     private Op(PatchSet.Id psId, DraftInput in) {
       this.psId = psId;
@@ -102,8 +105,7 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, UnprocessableEntityException,
-            PatchListNotAvailableException {
+        throws ResourceNotFoundException, UnprocessableEntityException {
       PatchSet ps = psUtil.get(ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
@@ -111,15 +113,23 @@
       String parentUuid = Url.decode(in.inReplyTo);
 
       comment =
-          commentsUtil.newComment(
-              ctx, in.path, ps.id(), in.side(), in.message.trim(), in.unresolved, parentUuid);
+          commentsUtil.newHumanComment(
+              ctx.getNotes(),
+              ctx.getUser(),
+              ctx.getWhen(),
+              in.path,
+              ps.id(),
+              in.side(),
+              in.message.trim(),
+              in.unresolved,
+              parentUuid);
       comment.setLineNbrAndRange(in.line, in.range);
       comment.tag = in.tag;
 
-      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
 
-      commentsUtil.putComments(
-          ctx.getUpdate(psId), Comment.Status.DRAFT, Collections.singleton(comment));
+      commentsUtil.putHumanComments(
+          ctx.getUpdate(psId), HumanComment.Status.DRAFT, Collections.singleton(comment));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 8ac2140..af4bf69 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -55,6 +55,7 @@
 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.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -128,6 +129,13 @@
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     rsrc.permissions().check(ChangePermission.ADD_PATCH_SET);
+    if (in.author != null) {
+      permissionBackend
+          .currentUser()
+          .project(rsrc.getProject())
+          .ref(rsrc.getChange().getDest().branch())
+          .check(RefPermission.FORGE_AUTHOR);
+    }
 
     ProjectState projectState =
         projectCache.get(rsrc.getProject()).orElseThrow(illegalState(rsrc.getProject()));
@@ -137,6 +145,10 @@
     if (merge == null || Strings.isNullOrEmpty(merge.source)) {
       throw new BadRequestException("merge.source must be non-empty");
     }
+    if (in.author != null
+        && (Strings.isNullOrEmpty(in.author.email) || Strings.isNullOrEmpty(in.author.name))) {
+      throw new BadRequestException("Author must specify name and email");
+    }
     in.baseChange = Strings.nullToEmpty(in.baseChange).trim();
 
     PatchSet ps = psUtil.current(rsrc.getNotes());
@@ -166,7 +178,10 @@
 
       Timestamp now = TimeUtil.nowTs();
       IdentifiedUser me = user.get().asIdentifiedUser();
-      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent author =
+          in.author == null
+              ? me.newCommitterIdent(now, serverTimeZone)
+              : new PersonIdent(in.author.name, in.author.email, now, serverTimeZone);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index f79209d..5b44957 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -23,6 +23,7 @@
 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.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -93,13 +94,17 @@
     }
 
     ChangeMessageInfo updatedMessageInfo =
-        createUpdatedChangeMessageInfo(resource.getChangeId(), resource.getChangeMessageIndex());
+        createUpdatedChangeMessageInfo(
+            resource.getChangeResource().getId(),
+            resource.getChangeResource().getProject(),
+            resource.getChangeMessageIndex());
     return Response.created(updatedMessageInfo);
   }
 
-  private ChangeMessageInfo createUpdatedChangeMessageInfo(Change.Id id, int targetIdx)
-      throws PermissionBackendException {
-    List<ChangeMessage> messages = changeMessagesUtil.byChange(notesFactory.createChecked(id));
+  private ChangeMessageInfo createUpdatedChangeMessageInfo(
+      Change.Id cId, Project.NameKey project, int targetIdx) throws PermissionBackendException {
+    List<ChangeMessage> messages =
+        changeMessagesUtil.byChange(notesFactory.createChecked(project, cId));
     ChangeMessage updatedChangeMessage = messages.get(targetIdx);
     AccountLoader accountLoader = accountLoaderFactory.create(true);
     ChangeMessageInfo info = createChangeMessageInfo(updatedChangeMessage, accountLoader);
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index f915728..044fd77 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -26,7 +26,7 @@
 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;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -45,7 +45,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class DeleteComment implements RestModifyView<CommentResource, DeleteCommentInput> {
+public class DeleteComment implements RestModifyView<HumanCommentResource, DeleteCommentInput> {
 
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
@@ -71,7 +71,7 @@
   }
 
   @Override
-  public Response<CommentInfo> apply(CommentResource rsrc, DeleteCommentInput input)
+  public Response<CommentInfo> apply(HumanCommentResource rsrc, DeleteCommentInput input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException,
           UpdateException {
     CurrentUser user = userProvider.get();
@@ -89,16 +89,18 @@
     }
 
     ChangeNotes updatedNotes =
-        notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
-    List<Comment> changeComments = commentsUtil.publishedByChange(updatedNotes);
-    Optional<Comment> updatedComment =
+        notesFactory.createChecked(
+            rsrc.getRevisionResource().getProject(),
+            rsrc.getRevisionResource().getChangeResource().getId());
+    List<HumanComment> changeComments = commentsUtil.publishedHumanCommentsByChange(updatedNotes);
+    Optional<HumanComment> updatedComment =
         changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
     if (!updatedComment.isPresent()) {
       // This should not happen as this endpoint should not remove the whole comment.
       throw new ResourceNotFoundException("comment not found: " + rsrc.getComment().key);
     }
 
-    return Response.ok(commentJson.get().newCommentFormatter().format(updatedComment.get()));
+    return Response.ok(commentJson.get().newHumanCommentFormatter().format(updatedComment.get()));
   }
 
   private static String getCommentNewMessage(String name, String reason) {
@@ -110,10 +112,10 @@
   }
 
   private class DeleteCommentOp implements BatchUpdateOp {
-    private final CommentResource rsrc;
+    private final HumanCommentResource rsrc;
     private final String newMessage;
 
-    DeleteCommentOp(CommentResource rsrc, String newMessage) {
+    DeleteCommentOp(HumanCommentResource rsrc, String newMessage) {
       this.rsrc = rsrc;
       this.newMessage = newMessage;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 89fc3b7..51a0b8e 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
-
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -27,8 +26,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -44,18 +41,13 @@
   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,
-      PatchListCache patchListCache) {
+      BatchUpdate.Factory updateFactory, CommentsUtil commentsUtil, PatchSetUtil psUtil) {
     this.updateFactory = updateFactory;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -78,9 +70,8 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, PatchListNotAvailableException {
-      Optional<Comment> maybeComment =
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
+      Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
@@ -90,9 +81,9 @@
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      Comment c = maybeComment.get();
-      setCommentCommitId(c, patchListCache, ctx.getChange(), ps);
-      commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
+      HumanComment c = maybeComment.get();
+      commentsUtil.setCommentCommitId(c, ctx.getChange(), ps);
+      commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 4c39763..4b813df 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -19,10 +19,10 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -77,6 +78,7 @@
   private final NotifyResolver notifyResolver;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   DeleteVote(
@@ -89,7 +91,8 @@
       DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
@@ -100,6 +103,7 @@
     this.notifyResolver = notifyResolver;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -225,11 +229,14 @@
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
         if (notify.shouldNotify()) {
-          ReplyToChangeSender cm = deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-          cm.setFrom(user.getAccountId());
-          cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
-          cm.setNotify(notify);
-          cm.send();
+          ReplyToChangeSender emailSender =
+              deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
+          emailSender.setFrom(user.getAccountId());
+          emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+          emailSender.setNotify(notify);
+          emailSender.setMessageId(
+              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+          emailSender.send();
         }
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index bab1ac9..ab5b9f4 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -64,7 +64,7 @@
       throws ResourceNotFoundException, AuthException {
     checkIdentifiedUser();
     String uuid = id.get();
-    for (Comment c :
+    for (HumanComment c :
         commentsUtil.draftByPatchSetAuthor(
             rev.getPatchSet().id(), rev.getAccountId(), rev.getNotes())) {
       if (uuid.equals(c.key.uuid)) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
index 08a963b..6822d91 100644
--- a/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
@@ -18,7 +18,7 @@
 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.common.AttentionSetInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountLoader;
@@ -41,15 +41,15 @@
   }
 
   @Override
-  public Response<Set<AttentionSetEntry>> apply(ChangeResource changeResource)
+  public Response<Set<AttentionSetInfo>> apply(ChangeResource changeResource)
       throws PermissionBackendException {
     AccountLoader accountLoader = accountLoaderFactory.create(true);
-    ImmutableSet<AttentionSetEntry> response =
+    ImmutableSet<AttentionSetInfo> response =
         // This filtering should match ChangeJson.
         additionsOnly(changeResource.getNotes().getAttentionSet()).stream()
             .map(
                 a ->
-                    new AttentionSetEntry(
+                    new AttentionSetInfo(
                         accountLoader.get(a.account()), Timestamp.from(a.timestamp()), a.reason()))
             .collect(toImmutableSet());
     accountLoader.fill();
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index c28741b..1ef3c4b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -27,11 +29,13 @@
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
@@ -43,6 +47,7 @@
         DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
   private final DynamicSet<ChangeAttributeFactory> attrFactories;
+  private final DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
 
@@ -57,9 +62,13 @@
   }
 
   @Inject
-  GetChange(ChangeJson.Factory json, DynamicSet<ChangeAttributeFactory> attrFactories) {
+  GetChange(
+      ChangeJson.Factory json,
+      DynamicSet<ChangeAttributeFactory> attrFactories,
+      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
     this.json = json;
     this.attrFactories = attrFactories;
+    this.pdiFactories = pdiFactories;
   }
 
   @Override
@@ -82,11 +91,17 @@
   }
 
   private ChangeJson newChangeJson() {
-    return json.create(options, this::buildPluginInfo);
+    return json.create(options, this::buildPluginInfo, this::createPluginDefinedInfos);
   }
 
   private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
     return PluginDefinedAttributesFactories.createAll(
         cd, this, Streams.stream(attrFactories.entries()));
   }
+
+  private ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds) {
+    return PluginDefinedAttributesFactories.createAll(
+        cds, this, Streams.stream(pdiFactories.entries()));
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetComment.java b/java/com/google/gerrit/server/restapi/change/GetComment.java
index 5103325..24085df 100644
--- a/java/com/google/gerrit/server/restapi/change/GetComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetComment.java
@@ -17,14 +17,14 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
-public class GetComment implements RestReadView<CommentResource> {
+public class GetComment implements RestReadView<HumanCommentResource> {
 
   private final Provider<CommentJson> commentJson;
 
@@ -34,7 +34,7 @@
   }
 
   @Override
-  public Response<CommentInfo> apply(CommentResource rsrc) throws PermissionBackendException {
-    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
+  public Response<CommentInfo> apply(HumanCommentResource rsrc) throws PermissionBackendException {
+    return Response.ok(commentJson.get().newHumanCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index f4e2ddd..8d51786 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 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;
@@ -54,9 +53,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
-import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
-import org.kohsuke.args4j.NamedOptionDef;
 import org.kohsuke.args4j.Option;
 import org.kohsuke.args4j.OptionDef;
 import org.kohsuke.args4j.spi.OptionHandler;
@@ -84,8 +81,9 @@
   @Option(name = "--whitespace")
   Whitespace whitespace;
 
+  // TODO(hiesel): Remove parameter when not used by callers (e.g. frontend) anymore.
   @Option(name = "--context", handler = ContextOptionHandler.class)
-  int context = DiffPreferencesInfo.DEFAULT_CONTEXT;
+  int context;
 
   @Option(name = "--intraline")
   boolean intraline;
@@ -114,11 +112,10 @@
     } else {
       prefs.ignoreWhitespace = Whitespace.IGNORE_LEADING_AND_TRAILING;
     }
-    prefs.context = context;
     prefs.intralineDifference = intraline;
     logger.atFine().log(
-        "diff preferences: ignoreWhitespace = %s, context = %s, intralineDifference = %s",
-        prefs.ignoreWhitespace, prefs.context, prefs.intralineDifference);
+        "diff preferences: ignoreWhitespace = %s, intralineDifference = %s",
+        prefs.ignoreWhitespace, prefs.intralineDifference);
 
     PatchScriptFactory psf;
     PatchSet basePatchSet = null;
@@ -143,7 +140,6 @@
     }
 
     try {
-      psf.setLoadComments(context != DiffPreferencesInfo.WHOLE_FILE_CONTEXT);
       PatchScript ps = psf.call();
       Project.NameKey projectName = resource.getRevision().getChange().getProject();
       ProjectState state = projectCache.get(projectName).orElseThrow(illegalState(projectName));
@@ -245,11 +241,6 @@
     return this;
   }
 
-  public GetDiff setContext(int context) {
-    this.context = context;
-    return this;
-  }
-
   public GetDiff setIntraline(boolean intraline) {
     this.intraline = intraline;
     return this;
@@ -274,6 +265,7 @@
     }
   }
 
+  // TODO(hiesel): Remove this class once clients don't send the context parameter anymore.
   public static class ContextOptionHandler extends OptionHandler<Short> {
 
     public ContextOptionHandler(CmdLineParser parser, OptionDef option, Setter<Short> setter) {
@@ -281,33 +273,14 @@
     }
 
     @Override
-    public final int parseArguments(Parameters params) throws CmdLineException {
-      final String value = params.getParameter(0);
-      short context;
-      if ("all".equalsIgnoreCase(value)) {
-        context = DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
-      } else {
-        try {
-          context = Short.parseShort(value, 10);
-          if (context < 0) {
-            throw new NumberFormatException();
-          }
-        } catch (NumberFormatException e) {
-          logger.atFine().withCause(e).log("invalid numeric value");
-          throw new CmdLineException(
-              owner,
-              localizable("\"%s\" is not a valid value for \"%s\""),
-              value,
-              ((NamedOptionDef) option).name());
-        }
-      }
-      setter.addValue(context);
+    public final int parseArguments(Parameters params) {
+      // Return 1 to consume the context parameter.
       return 1;
     }
 
     @Override
     public final String getDefaultMetaVariable() {
-      return "ALL|# LINES";
+      return "ignored";
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
index 797dc9e..ba07b47 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
@@ -35,6 +35,6 @@
 
   @Override
   public Response<CommentInfo> apply(DraftCommentResource rsrc) throws PermissionBackendException {
-    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
+    return Response.ok(commentJson.get().newHumanCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/HumanCommentFilter.java b/java/com/google/gerrit/server/restapi/change/HumanCommentFilter.java
new file mode 100644
index 0000000..0180042
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/HumanCommentFilter.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.restapi.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.HumanComment;
+
+/** Filter for human comments. */
+public interface HumanCommentFilter {
+
+  /**
+   * Filters the given comments. The returned comments are the ones which still remain after this
+   * filter was applied.
+   *
+   * @param comments comments which should be filtered
+   * @return remaining comments. Must not include comments which weren't included in the given
+   *     comments.
+   */
+  ImmutableList<HumanComment> filter(ImmutableList<HumanComment> comments);
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index e544509..e3b433c 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -18,8 +18,10 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -28,20 +30,31 @@
 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;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class ListChangeComments implements RestReadView<ChangeResource> {
   private final ChangeMessagesUtil changeMessagesUtil;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
 
+  private boolean includeContext;
+
+  /**
+   * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
+   * response will contain the lines of the source file where the comment was written.
+   *
+   * @param context If true, comment context will be attached to the response
+   */
+  @Option(name = "--enable-context")
+  public void setContext(boolean context) {
+    this.includeContext = context;
+  }
+
   @Inject
   ListChangeComments(
       ChangeData.Factory changeDataFactory,
@@ -64,30 +77,37 @@
     return getAsList(listComments(rsrc), rsrc);
   }
 
-  private Iterable<Comment> listComments(ChangeResource rsrc) {
+  private Iterable<HumanComment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return commentsUtil.publishedByChange(cd.notes());
+    return commentsUtil.publishedHumanCommentsByChange(cd.notes());
   }
 
-  private ImmutableList<CommentInfo> getAsList(Iterable<Comment> comments, ChangeResource rsrc)
+  private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
-    ImmutableList<CommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
+    ImmutableList<CommentInfo> commentInfos =
+        getCommentFormatter(rsrc.getProject()).formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfos;
   }
 
-  private Map<String, List<CommentInfo>> getAsMap(Iterable<Comment> comments, ChangeResource rsrc)
-      throws PermissionBackendException {
-    Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter().format(comments);
+  private Map<String, List<CommentInfo>> getAsMap(
+      Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
+    Map<String, List<CommentInfo>> commentInfosMap =
+        getCommentFormatter(rsrc.getProject()).format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfosMap;
   }
 
-  private CommentFormatter getCommentFormatter() {
-    return commentJson.get().setFillAccounts(true).setFillPatchSet(true).newCommentFormatter();
+  private CommentJson.HumanCommentFormatter getCommentFormatter(Project.NameKey project) {
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .setFillPatchSet(true)
+        .setEnableContext(includeContext, project)
+        .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 24e1d40..3841dc1 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -23,7 +23,7 @@
 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.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -46,7 +46,7 @@
     this.commentsUtil = commentsUtil;
   }
 
-  private Iterable<Comment> listComments(ChangeResource rsrc) {
+  private Iterable<HumanComment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     return commentsUtil.draftByChangeAuthor(cd.notes(), rsrc.getUser().getAccountId());
   }
@@ -68,7 +68,11 @@
     return getCommentFormatter().formatAsList(listComments(rsrc));
   }
 
-  private CommentFormatter getCommentFormatter() {
-    return commentJson.get().setFillAccounts(false).setFillPatchSet(true).newCommentFormatter();
+  private HumanCommentFormatter getCommentFormatter() {
+    return commentJson
+        .get()
+        .setFillAccounts(false)
+        .setFillPatchSet(true)
+        .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
index 0ed7d60..d841183 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
@@ -63,7 +63,7 @@
     List<RobotCommentInfo> commentInfos =
         robotCommentsMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, false);
     return Response.ok(robotCommentsMap);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedComments.java b/java/com/google/gerrit/server/restapi/change/ListPortedComments.java
new file mode 100644
index 0000000..6d6a02a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedComments.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.restapi.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+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 ListPortedComments implements RestReadView<RevisionResource> {
+
+  private final CommentsUtil commentsUtil;
+  private final CommentPorter commentPorter;
+  private final Provider<CommentJson> commentJson;
+
+  @Inject
+  public ListPortedComments(
+      Provider<CommentJson> commentJson, CommentsUtil commentsUtil, CommentPorter commentPorter) {
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+    this.commentPorter = commentPorter;
+  }
+
+  @Override
+  public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
+      throws PermissionBackendException {
+    PatchSet targetPatchset = revisionResource.getPatchSet();
+
+    List<HumanComment> allComments =
+        commentsUtil.publishedHumanCommentsByChange(revisionResource.getNotes());
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(
+            revisionResource.getNotes(),
+            targetPatchset,
+            allComments,
+            ImmutableList.of(new UnresolvedCommentFilter()));
+    return Response.ok(format(portedComments));
+  }
+
+  private Map<String, List<CommentInfo>> format(List<HumanComment> comments)
+      throws PermissionBackendException {
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .setFillPatchSet(true)
+        .newHumanCommentFormatter()
+        .format(comments);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
new file mode 100644
index 0000000..9b254f1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.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.restapi.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+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 ListPortedDrafts implements RestReadView<RevisionResource> {
+
+  private final CommentsUtil commentsUtil;
+  private final CommentPorter commentPorter;
+  private final Provider<CommentJson> commentJson;
+
+  @Inject
+  public ListPortedDrafts(
+      Provider<CommentJson> commentJson, CommentsUtil commentsUtil, CommentPorter commentPorter) {
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+    this.commentPorter = commentPorter;
+  }
+
+  @Override
+  public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
+      throws PermissionBackendException {
+    PatchSet targetPatchset = revisionResource.getPatchSet();
+
+    List<HumanComment> draftComments =
+        commentsUtil.draftByChangeAuthor(
+            revisionResource.getNotes(), revisionResource.getAccountId());
+    ImmutableList<HumanComment> portedDraftComments =
+        commentPorter.portComments(
+            revisionResource.getNotes(), targetPatchset, draftComments, ImmutableList.of());
+    return Response.ok(format(portedDraftComments));
+  }
+
+  private Map<String, List<CommentInfo>> format(List<HumanComment> comments)
+      throws PermissionBackendException {
+    return commentJson
+        .get()
+        // Always unset for draft comments as only draft comments of the requesting user are
+        // returned.
+        .setFillAccounts(false)
+        .setFillPatchSet(true)
+        .newHumanCommentFormatter()
+        .format(comments);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
index 25ef480..3d07d43 100644
--- a/java/com/google/gerrit/server/restapi/change/ListReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerJson;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
index de05d2a..88309ed 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -35,7 +35,7 @@
   }
 
   @Override
-  protected Iterable<Comment> listComments(RevisionResource rsrc) {
+  protected Iterable<HumanComment> listComments(RevisionResource rsrc) {
     ChangeNotes notes = rsrc.getNotes();
     return commentsUtil.publishedByPatchSet(notes, rsrc.getPatchSet().id());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
index 199a752..a5fbd92 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -39,7 +39,7 @@
     this.commentsUtil = commentsUtil;
   }
 
-  protected Iterable<Comment> listComments(RevisionResource rsrc) {
+  protected Iterable<HumanComment> listComments(RevisionResource rsrc) {
     return commentsUtil.draftByPatchSetAuthor(
         rsrc.getPatchSet().id(), rsrc.getAccountId(), rsrc.getNotes());
   }
@@ -55,7 +55,7 @@
         commentJson
             .get()
             .setFillAccounts(includeAuthorInfo())
-            .newCommentFormatter()
+            .newHumanCommentFormatter()
             .format(listComments(rsrc)));
   }
 
@@ -64,7 +64,7 @@
     return commentJson
         .get()
         .setFillAccounts(includeAuthorInfo())
-        .newCommentFormatter()
+        .newHumanCommentFormatter()
         .formatAsList(listComments(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
index 73b1f59..b44f637 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 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.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
index 742eaca..25f4005 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -64,7 +64,7 @@
       Iterable<RobotComment> comments, RevisionResource rsrc) throws PermissionBackendException {
     ImmutableList<RobotCommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, false);
     return commentInfos;
   }
 
@@ -74,7 +74,7 @@
     List<RobotCommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, false);
     return commentInfosMap;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index b84b5e3..7683ab7 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -16,9 +16,10 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.BranchOrderSection;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeIndexer;
@@ -41,8 +41,10 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -115,11 +117,12 @@
 
       if (otherBranches) {
         result.mergeableInto = new ArrayList<>();
-        BranchOrderSection branchOrder = projectState.getBranchOrderSection();
-        if (branchOrder != null) {
+        Optional<BranchOrderSection> branchOrder = projectState.getBranchOrderSection();
+        if (branchOrder.isPresent()) {
           int prefixLen = Constants.R_HEADS.length();
-          String[] names = branchOrder.getMoreStable(ref.getName());
-          Map<String, Ref> refs = git.getRefDatabase().exactRef(names);
+          List<String> names = branchOrder.get().getMoreStable(ref.getName());
+          Map<String, Ref> refs =
+              git.getRefDatabase().exactRef(names.toArray(new String[names.size()]));
           for (String n : names) {
             Ref other = refs.get(n);
             if (other == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 387d0a8..69e2788 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -18,10 +18,10 @@
 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;
-import static com.google.gerrit.server.change.CommentResource.COMMENT_KIND;
 import static com.google.gerrit.server.change.DraftCommentResource.DRAFT_COMMENT_KIND;
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
 import static com.google.gerrit.server.change.FixResource.FIX_KIND;
+import static com.google.gerrit.server.change.HumanCommentResource.COMMENT_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
 import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
@@ -29,6 +29,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
@@ -49,6 +50,7 @@
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
+import com.google.gerrit.server.util.AttentionSetEmail;
 
 public class Module extends RestApiModule {
   @Override
@@ -171,6 +173,9 @@
     post(FIX_KIND, "apply").to(ApplyFix.class);
     get(FIX_KIND, "preview").to(GetFixPreview.class);
 
+    get(REVISION_KIND, "ported_comments").to(ListPortedComments.class);
+    get(REVISION_KIND, "ported_drafts").to(ListPortedDrafts.class);
+
     child(REVISION_KIND, "files").to(Files.class);
     put(FILE_KIND, "reviewed").to(PutReviewed.class);
     delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
@@ -201,6 +206,7 @@
     factory(AccountLoader.Factory.class);
     factory(ChangeInserter.Factory.class);
     factory(ChangeResource.Factory.class);
+    factory(CommentContextLoader.Factory.class);
     factory(DeleteChangeOp.Factory.class);
     factory(DeleteReviewerByEmailOp.Factory.class);
     factory(DeleteReviewerOp.Factory.class);
@@ -217,5 +223,6 @@
     factory(SetTopicOp.Factory.class);
     factory(AddToAttentionSetOp.Factory.class);
     factory(RemoveFromAttentionSetOp.Factory.class);
+    factory(AttentionSetEmail.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 daa95c4..ecfb96d 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -23,10 +23,10 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 7008bb9..575a19d 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -40,19 +40,21 @@
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
-import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -79,7 +81,6 @@
 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.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -176,7 +177,9 @@
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final PluginSetContext<CommentValidator> commentValidators;
+  private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final boolean strictLabels;
+  private final boolean publishPatchSetLevelComment;
 
   @Inject
   PostReview(
@@ -199,7 +202,8 @@
       WorkInProgressOp.Factory workInProgressOpFactory,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      PluginSetContext<CommentValidator> commentValidators) {
+      PluginSetContext<CommentValidator> commentValidators,
+      ReplyAttentionSetUpdates replyAttentionSetUpdates) {
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
@@ -219,7 +223,10 @@
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.commentValidators = commentValidators;
+    this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
+    this.publishPatchSetLevelComment =
+        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
   }
 
   @Override
@@ -377,6 +384,9 @@
       NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
       bu.setNotify(notify);
 
+      // Adjust the attention set based on the input
+      replyAttentionSetUpdates.updateAttentionSet(
+          bu, revision.getNotes(), input, revision.getUser());
       bu.execute();
 
       // Re-read change to take into account results of the update.
@@ -561,8 +571,8 @@
     }
   }
 
-  private static <T extends CommentInput> Map<String, List<T>> cleanUpComments(
-      Map<String, List<T>> commentsPerPath) {
+  private static <T extends com.google.gerrit.extensions.client.Comment>
+      Map<String, List<T>> cleanUpComments(Map<String, List<T>> commentsPerPath) {
     Map<String, List<T>> cleanedUpCommentMap = new HashMap<>();
     for (Map.Entry<String, List<T>> e : commentsPerPath.entrySet()) {
       String path = e.getKey();
@@ -580,7 +590,8 @@
     return cleanedUpCommentMap;
   }
 
-  private static <T extends CommentInput> List<T> cleanUpComments(List<T> comments) {
+  private static <T extends com.google.gerrit.extensions.client.Comment> List<T> cleanUpComments(
+      List<T> comments) {
     return comments.stream()
         .filter(Objects::nonNull)
         .filter(comment -> !Strings.nullToEmpty(comment.message).trim().isEmpty())
@@ -591,7 +602,7 @@
     return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
   }
 
-  private <T extends CommentInput> void checkComments(
+  private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
       RevisionResource revision, Map<String, List<T>> commentsPerPath)
       throws BadRequestException, PatchListNotAvailableException {
     logger.atFine().log("checking comments");
@@ -606,6 +617,8 @@
         ensureLineIsNonNegative(comment.line, path);
         ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
         ensureRangeIsValid(path, comment.range);
+        ensureValidPatchsetLevelComment(path, comment);
+        ensureValidInReplyTo(revision.getNotes(), comment.inReplyTo);
       }
     }
   }
@@ -637,13 +650,32 @@
     }
   }
 
-  private static <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
-      String path, T comment) throws BadRequestException {
+  private static <T extends com.google.gerrit.extensions.client.Comment>
+      void ensureCommentNotOnMagicFilesOfAutoMerge(String path, T comment)
+          throws BadRequestException {
     if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
       throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
     }
   }
 
+  private static <T extends com.google.gerrit.extensions.client.Comment>
+      void ensureValidPatchsetLevelComment(String path, T comment) throws BadRequestException {
+    if (path.equals(PATCHSET_LEVEL)
+        && (comment.side != null || comment.range != null || comment.line != null)) {
+      throw new BadRequestException("Patchset-level comments can't have side, range, or line");
+    }
+  }
+
+  private void ensureValidInReplyTo(ChangeNotes changeNotes, String inReplyTo)
+      throws BadRequestException {
+    if (inReplyTo != null
+        && !commentsUtil.getPublishedHumanComment(changeNotes, inReplyTo).isPresent()
+        && !commentsUtil.getRobotComment(changeNotes, inReplyTo).isPresent()) {
+      throw new BadRequestException(
+          String.format("Invalid inReplyTo, comment %s not found", inReplyTo));
+    }
+  }
+
   private void checkRobotComments(
       RevisionResource revision, Map<String, List<RobotCommentInput>> in)
       throws BadRequestException, PatchListNotAvailableException {
@@ -703,7 +735,7 @@
     ensureReplacementsArePresent(commentPath, fixReplacementInfos);
 
     for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
-      ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
+      ensureReplacementPathIsSetAndNotPatchsetLevel(commentPath, fixReplacementInfo.path);
       ensureRangeIsSet(commentPath, fixReplacementInfo.range);
       ensureRangeIsValid(commentPath, fixReplacementInfo.range);
       ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
@@ -727,14 +759,20 @@
     }
   }
 
-  private static void ensureReplacementPathIsSet(String commentPath, String replacementPath)
-      throws BadRequestException {
+  private static void ensureReplacementPathIsSetAndNotPatchsetLevel(
+      String commentPath, String replacementPath) throws BadRequestException {
     if (replacementPath == null) {
       throw new BadRequestException(
           String.format(
               "A file path must be given for the replacement of the robot comment on %s",
               commentPath));
     }
+    if (replacementPath.equals(PATCHSET_LEVEL)) {
+      throw new BadRequestException(
+          String.format(
+              "A file path must not be %s for the replacement of the robot comment on %s",
+              PATCHSET_LEVEL, commentPath));
+    }
   }
 
   private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
@@ -795,8 +833,8 @@
   }
 
   /**
-   * Used to compare existing {@link Comment}-s with {@link CommentInput} comments by copying only
-   * the fields to compare.
+   * Used to compare existing {@link HumanComment}-s with {@link CommentInput} comments by copying
+   * only the fields to compare.
    */
   @AutoValue
   abstract static class CommentSetEntry {
@@ -859,7 +897,7 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceConflictException, UnprocessableEntityException, IOException,
-            PatchListNotAvailableException, CommentsRejectedException {
+            CommentsRejectedException {
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
       ps = psUtil.get(ctx.getNotes(), psId);
@@ -888,23 +926,45 @@
       }
       NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
       if (notify.shouldNotify()) {
-        email
-            .create(notify, notes, ps, user, message, comments, in.message, labelDelta)
-            .sendAsync();
+        try {
+          email
+              .create(
+                  notify,
+                  notes,
+                  ps,
+                  user,
+                  message,
+                  comments,
+                  in.message,
+                  labelDelta,
+                  ctx.getRepoView())
+              .sendAsync();
+        } catch (IOException ex) {
+          throw new StorageException(
+              String.format("Repository %s not found", ctx.getProject().get()), ex);
+        }
+      }
+      String comment = message.getMessage();
+      if (publishPatchSetLevelComment) {
+        // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
+        // added event. For backwards compatibility, patchset level comment has a higher priority
+        // than change message and should be used as comment in comment added event.
+        if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
+          List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
+          if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
+            CommentInput firstComment = patchSetLevelComments.get(0);
+            if (!Strings.isNullOrEmpty(firstComment.message)) {
+              comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
+            }
+          }
+        }
       }
       commentAdded.fire(
-          notes.getChange(),
-          ps,
-          user.state(),
-          message.getMessage(),
-          approvals,
-          oldApprovals,
-          ctx.getWhen());
+          notes.getChange(), ps, user.state(), comment, approvals, oldApprovals, ctx.getWhen());
     }
 
     private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
-        throws UnprocessableEntityException, PatchListNotAvailableException,
-            CommentsRejectedException {
+        throws CommentsRejectedException {
       Map<String, List<CommentInput>> inputComments = in.comments;
       if (inputComments == null) {
         inputComments = Collections.emptyMap();
@@ -912,7 +972,7 @@
 
       // HashMap instead of Collections.emptyMap() avoids warning about remove() on immutable
       // object.
-      Map<String, Comment> drafts = new HashMap<>();
+      Map<String, HumanComment> drafts = new HashMap<>();
       // If there are inputComments we need the deduplication loop below, so we have to read (and
       // publish) drafts here.
       if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
@@ -924,7 +984,7 @@
       }
 
       // This will be populated with Comment-s created from inputComments.
-      List<Comment> toPublish = new ArrayList<>();
+      List<HumanComment> toPublish = new ArrayList<>();
 
       Set<CommentSetEntry> existingComments =
           in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
@@ -935,12 +995,14 @@
       for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
         String path = entry.getKey();
         for (CommentInput inputComment : entry.getValue()) {
-          Comment comment = drafts.remove(Url.decode(inputComment.id));
+          HumanComment comment = drafts.remove(Url.decode(inputComment.id));
           if (comment == null) {
             String parent = Url.decode(inputComment.inReplyTo);
             comment =
-                commentsUtil.newComment(
-                    ctx,
+                commentsUtil.newHumanComment(
+                    ctx.getNotes(),
+                    ctx.getUser(),
+                    ctx.getWhen(),
                     path,
                     psId,
                     inputComment.side(),
@@ -954,7 +1016,7 @@
             comment.message = inputComment.message;
           }
 
-          setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+          commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
           comment.setLineNbrAndRange(inputComment.line, inputComment.range);
           comment.tag = in.tag;
 
@@ -984,7 +1046,7 @@
           break;
       }
       ChangeUpdate changeUpdate = ctx.getUpdate(psId);
-      commentsUtil.putComments(changeUpdate, Comment.Status.PUBLISHED, toPublish);
+      commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, toPublish);
       comments.addAll(toPublish);
       return !toPublish.isEmpty();
     }
@@ -1033,8 +1095,7 @@
       return !newRobotComments.isEmpty();
     }
 
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx)
-        throws PatchListNotAvailableException {
+    private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
       List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
 
       Set<CommentSetEntry> existingIds =
@@ -1054,8 +1115,7 @@
     }
 
     private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput)
-        throws PatchListNotAvailableException {
+        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
       RobotComment robotComment =
           commentsUtil.newRobotComment(
               ctx,
@@ -1070,7 +1130,7 @@
       robotComment.properties = robotCommentInput.properties;
       robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
       robotComment.tag = in.tag;
-      setCommentCommitId(robotComment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
       robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
       return robotComment;
     }
@@ -1104,7 +1164,7 @@
     }
 
     private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
-      return commentsUtil.publishedByChange(ctx.getNotes()).stream()
+      return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
           .map(CommentSetEntry::create)
           .collect(toSet());
     }
@@ -1115,18 +1175,12 @@
           .collect(toSet());
     }
 
-    private Map<String, Comment> changeDrafts(ChangeContext ctx) {
+    private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
       return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
-          .collect(
-              Collectors.toMap(
-                  c -> c.key.uuid,
-                  c -> {
-                    c.tag = in.tag;
-                    return c;
-                  }));
+          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
     }
 
-    private Map<String, Comment> patchSetDrafts(ChangeContext ctx) {
+    private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
       return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
           .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
     }
@@ -1255,8 +1309,6 @@
         return false;
       }
 
-      forceCallerAsReviewer(projectState, ctx, current, ups, del);
-
       return !del.isEmpty() || !ups.isEmpty();
     }
 
@@ -1285,7 +1337,7 @@
       for (PatchSetApproval psa : del) {
         LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
-        if (!lt.allowPostSubmit()) {
+        if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
         }
         Short prev = previous.get(normName);
@@ -1297,7 +1349,7 @@
       for (PatchSetApproval psa : ups) {
         LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
-        if (!lt.allowPostSubmit()) {
+        if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
         }
         Short prev = previous.get(normName);
@@ -1327,41 +1379,6 @@
       }
     }
 
-    private void forceCallerAsReviewer(
-        ProjectState projectState,
-        ChangeContext ctx,
-        Map<String, PatchSetApproval> current,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del) {
-      if (current.isEmpty() && ups.isEmpty()) {
-        // TODO Find another way to link reviewers to changes.
-        if (del.isEmpty()) {
-          // If no existing label is being set to 0, hack in the caller
-          // as a reviewer by picking the first server-wide LabelType.
-          List<LabelType> labelTypes = projectState.getLabelTypes(ctx.getNotes()).getLabelTypes();
-          if (labelTypes.isEmpty()) {
-            logger.atWarning().log(
-                "no label type found for project %s, change %s",
-                projectState.getName(), ctx.getChange().getChangeId());
-            return;
-          }
-
-          LabelId labelId = labelTypes.get(0).getLabelId();
-          ups.add(
-              ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag))
-                  .granted(ctx.getWhen())
-                  .build());
-        } else {
-          // Pick a random label that is about to be deleted and keep it.
-          Iterator<PatchSetApproval> i = del.iterator();
-          ups.add(i.next().toBuilder().value(0).granted(ctx.getWhen()).build());
-          i.remove();
-        }
-      }
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
-    }
-
     private Map<String, PatchSetApproval> scanLabels(
         ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
         throws IOException {
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 63cd7a3..84a3d89 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -14,9 +14,10 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -30,8 +31,6 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
 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.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -52,7 +51,6 @@
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final Provider<CommentJson> commentJson;
-  private final PatchListCache patchListCache;
 
   @Inject
   PutDraftComment(
@@ -60,14 +58,12 @@
       DeleteDraftComment delete,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      Provider<CommentJson> commentJson,
-      PatchListCache patchListCache) {
+      Provider<CommentJson> commentJson) {
     this.updateFactory = updateFactory;
     this.delete = delete;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.commentJson = commentJson;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -79,17 +75,24 @@
       throw new BadRequestException("id must match URL");
     } else if (in.line != null && in.line < 0) {
       throw new BadRequestException("line must be >= 0");
+    } else if (in.path.equals(PATCHSET_LEVEL)
+        && (in.side != null || in.range != null || in.line != null)) {
+      throw new BadRequestException("patchset-level comments can't have side, range, or line");
     } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
       throw new BadRequestException("range endLine must be on the same line as the comment");
+    } else if (in.inReplyTo != null
+        && !commentsUtil.getPublishedHumanComment(rsrc.getNotes(), in.inReplyTo).isPresent()
+        && !commentsUtil.getRobotComment(rsrc.getNotes(), in.inReplyTo).isPresent()) {
+      throw new BadRequestException(
+          String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
-
     try (BatchUpdate bu =
         updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op(rsrc.getComment().key, in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.ok(
-          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
+          commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
     }
   }
 
@@ -97,7 +100,7 @@
     private final Comment.Key key;
     private final DraftInput in;
 
-    private Comment comment;
+    private HumanComment comment;
 
     private Op(Comment.Key key, DraftInput in) {
       this.key = key;
@@ -105,17 +108,16 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, PatchListNotAvailableException {
-      Optional<Comment> maybeComment =
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
+      Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         // Disappeared out from under us. Can't easily fall back to insert,
         // because the input might be missing required fields. Just give up.
         throw new ResourceNotFoundException("comment not found: " + key);
       }
-      Comment origComment = maybeComment.get();
-      comment = new Comment(origComment);
+      HumanComment origComment = maybeComment.get();
+      comment = new HumanComment(origComment);
       // Copy constructor preserved old real author; replace with current real
       // user.
       ctx.getUser().updateRealAccountId(comment::setRealAuthor);
@@ -131,17 +133,19 @@
         // Updating the path alters the primary key, which isn't possible.
         // Delete then recreate the comment instead of an update.
 
-        commentsUtil.deleteComments(update, Collections.singleton(origComment));
+        commentsUtil.deleteHumanComments(update, Collections.singleton(origComment));
         comment.key.filename = in.path;
       }
-      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
-      commentsUtil.putComments(
-          update, Comment.Status.DRAFT, Collections.singleton(update(comment, in, ctx.getWhen())));
+      commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
+      commentsUtil.putHumanComments(
+          update,
+          HumanComment.Status.DRAFT,
+          Collections.singleton(update(comment, in, ctx.getWhen())));
       return true;
     }
   }
 
-  private static Comment update(Comment e, DraftInput in, Timestamp when) {
+  private static HumanComment update(HumanComment e, DraftInput in, Timestamp when) {
     if (in.side != null) {
       e.side = in.side();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 1ed7fd7..37318d0 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,6 +34,7 @@
 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.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -70,6 +72,7 @@
   private final PatchSetUtil psUtil;
   private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   PutMessage(
@@ -81,7 +84,8 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
@@ -91,6 +95,7 @@
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
+    this.urlFormatter = urlFormatter;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 00138b5..7df74f8 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.change.ChangeJson;
@@ -95,7 +96,9 @@
     this.start = start;
   }
 
-  @Option(name = "--no-limit", usage = "Return all results, overriding the default limit")
+  @Option(
+      name = "--no-limit",
+      usage = "Return all results, overriding the default limit. Ignored for anonymous users.")
   public void setNoLimit(boolean on) {
     this.noLimit = on;
   }
@@ -168,7 +171,7 @@
     if (start != null) {
       queryProcessor.setStart(start);
     }
-    if (noLimit != null) {
+    if (noLimit != null && !AnonymousUser.class.isAssignableFrom(userProvider.get().getClass())) {
       queryProcessor.setNoLimit(noLimit);
     }
     if (skipVisibility != null) {
@@ -187,7 +190,9 @@
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = queryProcessor.query(qb.parse(queries));
     List<List<ChangeInfo>> res =
-        json.create(options, queryProcessor.getAttributesFactory()).format(results);
+        json.create(
+                options, queryProcessor.getAttributesFactory(), queryProcessor.getInfosFactory())
+            .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/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index ccf375a..c4dd04e 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -15,13 +15,17 @@
 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.entities.Account;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 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.account.AccountResolver;
 import com.google.gerrit.server.change.AttentionSetEntryResource;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -33,20 +37,27 @@
 
 /** Removes a single user from the attention set. */
 public class RemoveFromAttentionSet
-    implements RestModifyView<AttentionSetEntryResource, RemoveFromAttentionSetInput> {
+    implements RestModifyView<AttentionSetEntryResource, AttentionSetInput> {
   private final BatchUpdate.Factory updateFactory;
   private final RemoveFromAttentionSetOp.Factory opFactory;
+  private final AccountResolver accountResolver;
+  private final NotifyResolver notifyResolver;
 
   @Inject
   RemoveFromAttentionSet(
-      BatchUpdate.Factory updateFactory, RemoveFromAttentionSetOp.Factory opFactory) {
+      BatchUpdate.Factory updateFactory,
+      RemoveFromAttentionSetOp.Factory opFactory,
+      AccountResolver accountResolver,
+      NotifyResolver notifyResolver) {
     this.updateFactory = updateFactory;
     this.opFactory = opFactory;
+    this.accountResolver = accountResolver;
+    this.notifyResolver = notifyResolver;
   }
 
   @Override
   public Response<Object> apply(
-      AttentionSetEntryResource attentionResource, RemoveFromAttentionSetInput input)
+      AttentionSetEntryResource attentionResource, AttentionSetInput input)
       throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
           UpdateException {
     if (input == null) {
@@ -56,13 +67,30 @@
     if (input.reason.isEmpty()) {
       throw new BadRequestException("missing field: reason");
     }
+    input.user = Strings.nullToEmpty(input.user).trim();
+    if (!input.user.isEmpty()) {
+      Account.Id attentionUserId = null;
+      try {
+        attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
+      } catch (AccountResolver.UnresolvableAccountException ex) {
+        throw new BadRequestException(
+            "The user specified in the input body couldn't be found.", ex);
+      }
+      if (attentionUserId.get() != attentionResource.getAccountId().get()) {
+        throw new BadRequestException(
+            "The field \"user\" must be empty, or must match the user specified in the URL.");
+      }
+    }
     ChangeResource changeResource = attentionResource.getChangeResource();
     try (BatchUpdate bu =
         updateFactory.create(
             changeResource.getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
       RemoveFromAttentionSetOp op =
-          opFactory.create(attentionResource.getAccountId(), input.reason);
+          opFactory.create(attentionResource.getAccountId(), input.reason, true);
       bu.addOp(changeResource.getId(), op);
+      NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+      NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+      bu.setNotify(notifyResult);
       bu.execute();
     }
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
new file mode 100644
index 0000000..65c0cda
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -0,0 +1,370 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.change.AddToAttentionSetOp;
+import com.google.gerrit.server.change.AttentionSetUnchangedOp;
+import com.google.gerrit.server.change.CommentThread;
+import com.google.gerrit.server.change.CommentThreads;
+import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+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.update.BatchUpdate;
+import com.google.gerrit.server.util.AttentionSetUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * This class is used to update the attention set when performing a review or replying on a change.
+ */
+public class ReplyAttentionSetUpdates {
+
+  private final PermissionBackend permissionBackend;
+  private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
+  private final RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountResolver accountResolver;
+  private final ServiceUserClassifier serviceUserClassifier;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  ReplyAttentionSetUpdates(
+      PermissionBackend permissionBackend,
+      AddToAttentionSetOp.Factory addToAttentionSetOpFactory,
+      RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory,
+      ApprovalsUtil approvalsUtil,
+      AccountResolver accountResolver,
+      ServiceUserClassifier serviceUserClassifier,
+      CommentsUtil commentsUtil) {
+    this.permissionBackend = permissionBackend;
+    this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
+    this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.accountResolver = accountResolver;
+    this.serviceUserClassifier = serviceUserClassifier;
+    this.commentsUtil = commentsUtil;
+  }
+
+  /** Adjusts the attention set but only based on the automatic rules. */
+  public void processAutomaticAttentionSetRulesOnReply(
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      boolean readyForReview,
+      CurrentUser currentUser,
+      List<HumanComment> commentsToBePublished) {
+    if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
+      return;
+    }
+    processRules(
+        bu,
+        changeNotes,
+        readyForReview,
+        currentUser,
+        commentsToBePublished.stream().collect(toImmutableSet()));
+  }
+
+  /**
+   * Adjusts the attention set by adding and removing users. If the same user should be added and
+   * removed or added/removed twice, the user will only be added/removed once, based on first
+   * addition/removal.
+   */
+  public void updateAttentionSet(
+      BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser)
+      throws BadRequestException, IOException, PermissionBackendException,
+          UnprocessableEntityException, ConfigInvalidException {
+    processManualUpdates(bu, changeNotes, input);
+    if (input.ignoreAutomaticAttentionSetRules) {
+
+      // If we ignore automatic attention set rules it means we need to pass this information to
+      // ChangeUpdate. Also, we should stop all other attention set updates that are part of
+      // this method and happen in PostReview.
+      bu.addOp(changeNotes.getChangeId(), new AttentionSetUnchangedOp());
+      return;
+    }
+    if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
+      botsWithNegativeLabelsAddOwnerAndUploader(bu, changeNotes, input);
+      return;
+    }
+
+    processRules(
+        bu,
+        changeNotes,
+        isReadyForReview(changeNotes, input),
+        currentUser,
+        getAllNewComments(changeNotes, input, currentUser));
+  }
+
+  private ImmutableSet<HumanComment> getAllNewComments(
+      ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser) {
+    Set<HumanComment> newComments = new HashSet<>();
+    if (input.comments != null) {
+      for (ReviewInput.CommentInput commentInput :
+          input.comments.values().stream().flatMap(x -> x.stream()).collect(Collectors.toList())) {
+        newComments.add(
+            commentsUtil.newHumanComment(
+                changeNotes,
+                currentUser,
+                TimeUtil.nowTs(),
+                commentInput.path,
+                commentInput.patchSet == null
+                    ? changeNotes.getChange().currentPatchSetId()
+                    : PatchSet.id(changeNotes.getChange().getId(), commentInput.patchSet),
+                commentInput.side(),
+                commentInput.message,
+                commentInput.unresolved,
+                commentInput.inReplyTo));
+      }
+    }
+    List<HumanComment> drafts = new ArrayList<>();
+    if (input.drafts == ReviewInput.DraftHandling.PUBLISH) {
+      drafts =
+          commentsUtil.draftByPatchSetAuthor(
+              changeNotes.getChange().currentPatchSetId(), currentUser.getAccountId(), changeNotes);
+    }
+    if (input.drafts == ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS) {
+      drafts = commentsUtil.draftByChangeAuthor(changeNotes, currentUser.getAccountId());
+    }
+    return Stream.concat(newComments.stream(), drafts.stream()).collect(toImmutableSet());
+  }
+
+  /**
+   * Process the automatic rules of the attention set. All of the automatic rules except
+   * adding/removing reviewers and entering/exiting WIP state are done here, and the rest are done
+   * in {@link ChangeUpdate}
+   */
+  private void processRules(
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      boolean readyForReview,
+      CurrentUser currentUser,
+      ImmutableSet<HumanComment> allNewComments) {
+    // Replying removes the publishing user from the attention set.
+    removeFromAttentionSet(bu, changeNotes, currentUser.getAccountId(), "removed on reply", false);
+
+    Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
+    Account.Id owner = changeNotes.getChange().getOwner();
+
+    // The rest of the conditions only apply if the change is open.
+    if (changeNotes.getChange().getStatus().isClosed()) {
+      // We still add the owner if a new comment thread was created, on closed changes.
+      if (allNewComments.stream().anyMatch(c -> c.parentUuid == null)) {
+        addToAttentionSet(bu, changeNotes, owner, "A new comment thread was created", false);
+      }
+      return;
+    }
+    // The rest of the conditions only apply if the change is ready for review.
+    if (!readyForReview) {
+      return;
+    }
+
+    if (!currentUser.getAccountId().equals(owner)) {
+      addToAttentionSet(bu, changeNotes, owner, "Someone else replied on the change", false);
+    }
+    if (!owner.equals(uploader) && !currentUser.getAccountId().equals(uploader)) {
+      addToAttentionSet(bu, changeNotes, uploader, "Someone else replied on the change", false);
+    }
+
+    addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments);
+  }
+
+  /** Adds all authors of all comment threads that received a reply during this update */
+  private void addAllAuthorsOfCommentThreads(
+      BatchUpdate bu, ChangeNotes changeNotes, ImmutableSet<HumanComment> allNewComments) {
+    List<HumanComment> publishedComments = commentsUtil.publishedHumanCommentsByChange(changeNotes);
+    ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
+        CommentThreads.forComments(publishedComments).getThreadsForChildren(allNewComments);
+
+    ImmutableSet<Account.Id> repliedToUsers =
+        repliedToCommentThreads.stream()
+            .map(CommentThread::comments)
+            .flatMap(Collection::stream)
+            .map(comment -> comment.author.getId())
+            .collect(toImmutableSet());
+    ImmutableSet<Account.Id> possibleUsersToAdd = approvalsUtil.getReviewers(changeNotes).all();
+    SetView<Account.Id> usersToAdd = Sets.intersection(possibleUsersToAdd, repliedToUsers);
+
+    for (Account.Id user : usersToAdd) {
+      addToAttentionSet(
+          bu, changeNotes, user, "Someone else replied on a comment you posted", false);
+    }
+  }
+
+  /** Process the manual updates of the attention set. */
+  private void processManualUpdates(BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input)
+      throws BadRequestException, IOException, PermissionBackendException,
+          UnprocessableEntityException, ConfigInvalidException {
+    Set<Account.Id> accountsChangedInCommit = new HashSet<>();
+    // If we specify a user to remove, and the user is in the attention set, we remove it.
+    if (input.removeFromAttentionSet != null) {
+      for (AttentionSetInput remove : input.removeFromAttentionSet) {
+        removeFromAttentionSet(bu, changeNotes, remove, accountsChangedInCommit);
+      }
+    }
+
+    // If we don't specify a user to remove, but we specify addition for that user, the user will be
+    // added if they are not in the attention set yet.
+    if (input.addToAttentionSet != null) {
+      for (AttentionSetInput add : input.addToAttentionSet) {
+        addToAttentionSet(bu, changeNotes, add, accountsChangedInCommit);
+      }
+    }
+  }
+
+  /**
+   * Bots don't process automatic rules, the only attention set change they do is this rule: Add
+   * owner and uploader when a bot votes negatively.
+   */
+  private void botsWithNegativeLabelsAddOwnerAndUploader(
+      BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
+    if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
+      Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
+      Account.Id owner = changeNotes.getChange().getOwner();
+      addToAttentionSet(bu, changeNotes, owner, "A robot voted negatively on a label", false);
+      if (!owner.equals(uploader)) {
+        addToAttentionSet(bu, changeNotes, uploader, "A robot voted negatively on a label", false);
+      }
+    }
+  }
+
+  /**
+   * Adds the user to the attention set
+   *
+   * @param bu BatchUpdate to perform the updates to the attention set
+   * @param changeNotes current change
+   * @param user user to add to the attention set
+   * @param reason reason for adding
+   * @param notify whether or not to notify about this addition
+   */
+  private void addToAttentionSet(
+      BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
+    AddToAttentionSetOp addOwnerToAttentionSet =
+        addToAttentionSetOpFactory.create(user, reason, notify);
+    bu.addOp(changeNotes.getChangeId(), addOwnerToAttentionSet);
+  }
+
+  /**
+   * Removes the user from the attention set
+   *
+   * @param bu BatchUpdate to perform the updates to the attention set.
+   * @param changeNotes current change.
+   * @param user user to add remove from the attention set.
+   * @param reason reason for removing.
+   * @param notify whether or not to notify about this removal.
+   */
+  private void removeFromAttentionSet(
+      BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
+    RemoveFromAttentionSetOp removeFromAttentionSetOp =
+        removeFromAttentionSetOpFactory.create(user, reason, notify);
+    bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
+  }
+
+  private static boolean isReadyForReview(ChangeNotes changeNotes, ReviewInput input) {
+    return (!changeNotes.getChange().isWorkInProgress() && !input.workInProgress) || input.ready;
+  }
+
+  private void addToAttentionSet(
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      AttentionSetInput add,
+      Set<Account.Id> accountsChangedInCommit)
+      throws BadRequestException, IOException, PermissionBackendException,
+          UnprocessableEntityException, ConfigInvalidException {
+    AttentionSetUtil.validateInput(add);
+    Account.Id attentionUserId =
+        getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+
+    addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+  }
+
+  private void removeFromAttentionSet(
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      AttentionSetInput remove,
+      Set<Account.Id> accountsChangedInCommit)
+      throws BadRequestException, IOException, PermissionBackendException,
+          UnprocessableEntityException, ConfigInvalidException {
+    AttentionSetUtil.validateInput(remove);
+    Account.Id attentionUserId =
+        getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+
+    removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+  }
+
+  private Account.Id getAccountId(ChangeNotes changeNotes, String user)
+      throws ConfigInvalidException, IOException, UnprocessableEntityException,
+          PermissionBackendException {
+    Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
+    try {
+      permissionBackend
+          .absentUser(attentionUserId)
+          .change(changeNotes)
+          .check(ChangePermission.READ);
+    } catch (AuthException e) {
+      if (!changeNotes.getChange().isPrivate()) {
+        // If the change is private, it is okay to add the user to the attention set since that
+        // person will be granted visibility when a reviewer.
+        throw new UnprocessableEntityException(
+            "Can't add to attention set: Read not permitted for " + attentionUserId, e);
+      }
+    }
+    return attentionUserId;
+  }
+
+  private Account.Id getAccountIdAndValidateUser(
+      ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
+      throws ConfigInvalidException, IOException, PermissionBackendException,
+          UnprocessableEntityException, BadRequestException {
+    Account.Id attentionUserId = getAccountId(changeNotes, user);
+    if (accountsChangedInCommit.contains(attentionUserId)) {
+      throw new BadRequestException(
+          String.format(
+              "%s can not be added/removed twice, and can not be added and "
+                  + "removed at the same time",
+              user));
+    }
+    accountsChangedInCommit.add(attentionUserId);
+    return attentionUserId;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index a72192e..7faf8e0 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -65,6 +66,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeRestored changeRestored;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   Restore(
@@ -74,7 +76,8 @@
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeRestored changeRestored,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
     this.restoredSenderFactory = restoredSenderFactory;
     this.json = json;
@@ -82,6 +85,7 @@
     this.psUtil = psUtil;
     this.changeRestored = changeRestored;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -146,10 +150,13 @@
     @Override
     public void postUpdate(Context ctx) {
       try {
-        ReplyToChangeSender cm = restoredSenderFactory.create(ctx.getProject(), change.getId());
-        cm.setFrom(ctx.getAccountId());
-        cm.setChangeMessage(message.getMessage(), ctx.getWhen());
-        cm.send();
+        ReplyToChangeSender emailSender =
+            restoredSenderFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 88db66e..cb91faa 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -58,6 +58,7 @@
 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.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
@@ -126,6 +127,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final GetRelated getRelated;
+  private final MessageIdGenerator messageIdGenerator;
 
   private CherryPickInput cherryPickInput;
   private List<ChangeInfo> results;
@@ -154,7 +156,8 @@
       NotifyResolver notifyResolver,
       BatchUpdate.Factory updateFactory,
       ChangeResource.Factory changeResourceFactory,
-      GetRelated getRelated) {
+      GetRelated getRelated,
+      MessageIdGenerator messageIdGenerator) {
     this.queryProvider = queryProvider;
     this.user = user;
     this.permissionBackend = permissionBackend;
@@ -175,6 +178,7 @@
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.getRelated = getRelated;
+    this.messageIdGenerator = messageIdGenerator;
     results = new ArrayList<>();
     cherryPickInput = null;
   }
@@ -199,6 +203,7 @@
 
     checkPermissionsForAllChanges(changeResource, changeDatas);
     input.topic = createTopic(input.topic, submissionId);
+
     return Response.ok(revertSubmission(changeDatas, input));
   }
 
@@ -254,6 +259,9 @@
       cherryPickInput.base = null;
       Project.NameKey project = projectAndBranch.project();
       cherryPickInput.destination = projectAndBranch.branch();
+      if (revertInput.workInProgress) {
+        cherryPickInput.notify = NotifyHandling.OWNER;
+      }
       Collection<ChangeData> changesInProjectAndBranch =
           changesPerProjectAndBranch.get(projectAndBranch);
 
@@ -328,7 +336,11 @@
       bu.addOp(
           changeNotes.getChange().getId(),
           new CreateCherryPickOp(
-              revCommitId, generatedChangeId, cherryPickRevertChangeId, timestamp));
+              revCommitId,
+              generatedChangeId,
+              cherryPickRevertChangeId,
+              timestamp,
+              revertInput.workInProgress));
       bu.addOp(changeNotes.getChange().getId(), new PostRevertedMessageOp(generatedChangeId));
       bu.addOp(
           cherryPickRevertChangeId,
@@ -346,7 +358,11 @@
         commitUtil.createRevertChange(changeNotes, user.get(), revertInput, timestamp);
     results.add(json.noOptions().format(changeNotes.getProjectName(), revertId));
     cherryPickInput.base =
-        changeNotesFactory.createChecked(revertId).getCurrentPatchSet().commitId().getName();
+        changeNotesFactory
+            .createChecked(changeNotes.getProjectName(), revertId)
+            .getCurrentPatchSet()
+            .commitId()
+            .getName();
   }
 
   private CherryPickInput createCherryPickInput(RevertInput revertInput) {
@@ -545,16 +561,19 @@
     private final ObjectId computedChangeId;
     private final Change.Id cherryPickRevertChangeId;
     private final Timestamp timestamp;
+    private final boolean workInProgress;
 
     CreateCherryPickOp(
         ObjectId revCommitId,
         ObjectId computedChangeId,
         Change.Id cherryPickRevertChangeId,
-        Timestamp timestamp) {
+        Timestamp timestamp,
+        Boolean workInProgress) {
       this.revCommitId = revCommitId;
       this.computedChangeId = computedChangeId;
       this.cherryPickRevertChangeId = cherryPickRevertChangeId;
       this.timestamp = timestamp;
+      this.workInProgress = workInProgress;
     }
 
     @Override
@@ -571,11 +590,12 @@
               timestamp,
               change.getId(),
               computedChangeId,
-              cherryPickRevertChangeId);
+              cherryPickRevertChangeId,
+              workInProgress);
       // save the commit as base for next cherryPick of that branch
       cherryPickInput.base =
           changeNotesFactory
-              .createChecked(cherryPickResult.changeId())
+              .createChecked(ctx.getProject(), cherryPickResult.changeId())
               .getCurrentPatchSet()
               .commitId()
               .getName();
@@ -596,12 +616,16 @@
     @Override
     public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(
-          change, changeNotesFactory.createChecked(revertChangeId).getChange(), ctx.getWhen());
+          change,
+          changeNotesFactory.createChecked(ctx.getProject(), 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();
+        RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.setNotify(ctx.getNotify(change.getId()));
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot send email for revert change %s", change.getId());
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index d702142..4bfcf14 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 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.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeResource;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 676cc07..38be27e 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -23,8 +23,8 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -51,6 +51,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
@@ -130,6 +131,7 @@
   private final IndexConfig indexConfig;
   private final AccountControl.Factory accountControlFactory;
   private final Provider<CurrentUser> self;
+  private final ServiceUserClassifier serviceUserClassifier;
 
   @Inject
   ReviewersUtil(
@@ -143,7 +145,8 @@
       AccountIndexCollection accountIndexes,
       IndexConfig indexConfig,
       AccountControl.Factory accountControlFactory,
-      Provider<CurrentUser> self) {
+      Provider<CurrentUser> self,
+      ServiceUserClassifier serviceUserClassifier) {
     this.accountLoaderFactory = accountLoaderFactory;
     this.accountQueryBuilder = accountQueryBuilder;
     this.accountIndexRewriter = accountIndexRewriter;
@@ -155,6 +158,7 @@
     this.indexConfig = indexConfig;
     this.accountControlFactory = accountControlFactory;
     this.self = self;
+    this.serviceUserClassifier = serviceUserClassifier;
   }
 
   public interface VisibilityControl {
@@ -200,13 +204,17 @@
             reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
     logger.atFine().log("Sorted recommendations: %s", sortedRecommendations);
 
-    // Filter accounts by visibility and enforce limit
+    // Filter accounts by visibility, skip service users and enforce limit
     List<Account.Id> filteredRecommendations = new ArrayList<>();
     try (Timer0.Context ctx = metrics.filterVisibility.start()) {
       for (Account.Id reviewer : sortedRecommendations) {
         if (filteredRecommendations.size() >= limit) {
           break;
         }
+        if (suggestReviewers.isSkipServiceUsers()
+            && serviceUserClassifier.isServiceUser(reviewer)) {
+          continue;
+        }
         // Check if change is visible to reviewer and if the current user can see reviewer
         if (visibilityControl.isVisibleTo(reviewer)
             && accountControlFactory.get().canSee(reviewer)) {
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
index ac0945d..2651ab5 100644
--- a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -23,7 +24,6 @@
 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.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index e071c89..71ff493 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -31,6 +31,8 @@
 
   private static final int DEFAULT_MAX_SUGGESTED = 10;
 
+  private static final boolean DEFAULT_SKIP_SERVICE_USERS = true;
+
   protected final ReviewersUtil reviewersUtil;
 
   private final boolean suggestAccounts;
@@ -39,6 +41,7 @@
   protected int limit;
   protected String query;
   protected final int maxSuggestedReviewers;
+  protected boolean skipServiceUsers;
 
   @Option(
       name = "--limit",
@@ -78,6 +81,10 @@
     return maxAllowedWithoutConfirmation;
   }
 
+  public boolean isSkipServiceUsers() {
+    return skipServiceUsers;
+  }
+
   @Inject
   public SuggestReviewers(
       AccountVisibility av, @GerritServerConfig Config cfg, ReviewersUtil reviewersUtil) {
@@ -100,6 +107,9 @@
             ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
 
     logger.atFine().log("AccountVisibility: %s", av.name());
+
+    this.skipServiceUsers =
+        cfg.getBoolean("suggest", "skipServiceUsers", DEFAULT_SKIP_SERVICE_USERS);
   }
 
   public static GerritConfigListener configListener() {
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index e0398c7..02c2ff0 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
index cb52fcb..ecb455e 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
diff --git a/java/com/google/gerrit/server/restapi/change/UnresolvedCommentFilter.java b/java/com/google/gerrit/server/restapi/change/UnresolvedCommentFilter.java
new file mode 100644
index 0000000..e75ccca
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/UnresolvedCommentFilter.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.server.restapi.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.change.CommentThread;
+import com.google.gerrit.server.change.CommentThreads;
+import java.util.Collection;
+
+/** A filter which only keeps comments which are part of an unresolved {@link CommentThread}. */
+public class UnresolvedCommentFilter implements HumanCommentFilter {
+
+  @Override
+  public ImmutableList<HumanComment> filter(ImmutableList<HumanComment> comments) {
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    return commentThreads.stream()
+        .filter(CommentThread::unresolved)
+        .map(CommentThread::comments)
+        .flatMap(Collection::stream)
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/AgreementJson.java b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
index d5c085b..92dd489 100644
--- a/java/com/google/gerrit/server/restapi/config/AgreementJson.java
+++ b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.restapi.config;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.AgreementInfo;
diff --git a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
index 83b0262..8185281 100644
--- a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
@@ -19,9 +19,9 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 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.ConfigResource;
 import com.google.gerrit.server.config.DefaultPreferencesCache;
+import com.google.gerrit.server.config.PreferencesParserUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -41,7 +41,7 @@
   public Response<DiffPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     return Response.ok(
-        StoredPreferences.parseDiffPreferences(
+        PreferencesParserUtil.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 95fc10e..bb9e483 100644
--- a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
@@ -19,9 +19,9 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 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.ConfigResource;
 import com.google.gerrit.server.config.DefaultPreferencesCache;
+import com.google.gerrit.server.config.PreferencesParserUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -40,7 +40,7 @@
   public Response<EditPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     return Response.ok(
-        StoredPreferences.parseEditPreferences(
+        PreferencesParserUtil.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 8a28d55..288055b 100644
--- a/java/com/google/gerrit/server/restapi/config/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
@@ -17,9 +17,9 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 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.ConfigResource;
 import com.google.gerrit.server.config.DefaultPreferencesCache;
+import com.google.gerrit.server.config.PreferencesParserUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -38,7 +38,7 @@
   public Response<GeneralPreferencesInfo> apply(ConfigResource rsrc)
       throws IOException, ConfigInvalidException {
     return Response.ok(
-        StoredPreferences.parseGeneralPreferences(
+        PreferencesParserUtil.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 c83bf42..780c60a 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -19,7 +19,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.extensions.common.AccountDefaultDisplayName;
 import com.google.gerrit.extensions.common.AccountsInfo;
 import com.google.gerrit.extensions.common.AuthInfo;
@@ -174,7 +174,7 @@
 
     if (info.useContributorAgreements != null) {
       Collection<ContributorAgreement> agreements =
-          projectCache.getAllProjects().getConfig().getContributorAgreements();
+          projectCache.getAllProjects().getConfig().getContributorAgreements().values();
       if (!agreements.isEmpty()) {
         info.contributorAgreements = Lists.newArrayListWithCapacity(agreements.size());
         for (ContributorAgreement agreement : agreements) {
@@ -238,8 +238,9 @@
     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));
+        toBoolean(this.config.getBoolean("change", null, "enableAttentionSet", true));
+    info.enableAssignee =
+        toBoolean(this.config.getBoolean("change", null, "enableAssignee", false));
     return info;
   }
 
@@ -335,17 +336,20 @@
   }
 
   private static final String DEFAULT_THEME = "/static/" + SitePaths.THEME_FILENAME;
+  private static final String DEFAULT_THEME_JS = "/static/" + SitePaths.THEME_JS_FILENAME;
 
   private String getDefaultTheme() {
     if (config.getString("theme", null, "enableDefault") == null) {
       // If not explicitly enabled or disabled, check for the existence of the theme file.
-      return Files.exists(sitePaths.site_theme) ? DEFAULT_THEME : null;
+      return Files.exists(sitePaths.site_theme_js)
+          ? DEFAULT_THEME_JS
+          : Files.exists(sitePaths.site_theme) ? DEFAULT_THEME : null;
     }
     if (config.getBoolean("theme", null, "enableDefault", true)) {
       // Return non-null theme path without checking for file existence. Even if the file doesn't
       // exist under the site path, it may be served from a CDN (in which case it's up to the admin
       // to also pass a proper asset path to the index Soy template).
-      return DEFAULT_THEME;
+      return DEFAULT_THEME_JS;
     }
     return null;
   }
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index 93d095d..700a2ab 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -17,9 +17,9 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountInfo;
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index 3fd3f29..23fa73d 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -18,8 +18,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index e5a1478..74ca721 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -18,9 +18,9 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
index a7b2e2d..fa52a79 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -16,9 +16,9 @@
 
 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;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
index b9d6ca8..fe67635 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
@@ -16,8 +16,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index 508547d..8a469f1 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -16,10 +16,10 @@
 
 import static java.util.Comparator.comparing;
 
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
diff --git a/java/com/google/gerrit/server/restapi/group/GetDescription.java b/java/com/google/gerrit/server/restapi/group/GetDescription.java
index b770281..f65b5e0 100644
--- a/java/com/google/gerrit/server/restapi/group/GetDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/GetDescription.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
diff --git a/java/com/google/gerrit/server/restapi/group/GetOwner.java b/java/com/google/gerrit/server/restapi/group/GetOwner.java
index e8bdfaa..2ab9a69c 100644
--- a/java/com/google/gerrit/server/restapi/group/GetOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/GetOwner.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.group;
 
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
index 99c9df7..e1459c3 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupJson.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -19,8 +19,8 @@
 
 import com.google.common.base.Strings;
 import com.google.common.base.Suppliers;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index fd7bc26..08cc974 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index bcb199f..3e2a577 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -21,10 +21,10 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.client.ListOption;
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 23f0aa7..87b00c1 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -20,9 +20,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -80,13 +80,18 @@
       throws PermissionBackendException {
     Optional<InternalGroup> group = groupCache.get(groupUuid);
     if (group.isPresent()) {
-      InternalGroupDescription internalGroup = new InternalGroupDescription(group.get());
-      GroupControl groupControl = groupControlFactory.controlFor(internalGroup);
-      return getTransitiveMembers(internalGroup, groupControl);
+      return getTransitiveMembers(group.get());
     }
     return ImmutableList.of();
   }
 
+  public List<AccountInfo> getTransitiveMembers(InternalGroup group)
+      throws PermissionBackendException {
+    InternalGroupDescription internalGroup = new InternalGroupDescription(group);
+    GroupControl groupControl = groupControlFactory.controlFor(internalGroup);
+    return getTransitiveMembers(internalGroup, groupControl);
+  }
+
   private List<AccountInfo> getTransitiveMembers(
       GroupDescription.Internal group, GroupControl groupControl)
       throws PermissionBackendException {
@@ -110,6 +115,14 @@
     return toAccountInfos(directMembers);
   }
 
+  protected List<AccountInfo> getMembers(InternalGroup group) throws PermissionBackendException {
+    if (recursive) {
+      return getTransitiveMembers(group);
+    } else {
+      return getDirectMembers(group);
+    }
+  }
+
   private List<AccountInfo> toAccountInfos(Set<Account.Id> members)
       throws PermissionBackendException {
     AccountLoader accountLoader = accountLoaderFactory.create(true);
diff --git a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
index 540718f..776c17c 100644
--- a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
@@ -18,8 +18,8 @@
 import static java.util.Comparator.comparing;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.Response;
diff --git a/java/com/google/gerrit/server/restapi/group/MembersCollection.java b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
index 6dfb2b6..79f3d6a 100644
--- a/java/com/google/gerrit/server/restapi/group/MembersCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.group;
 
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
diff --git a/java/com/google/gerrit/server/restapi/group/PutDescription.java b/java/com/google/gerrit/server/restapi/group/PutDescription.java
index 8fe4b20..942e680 100644
--- a/java/com/google/gerrit/server/restapi/group/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/PutDescription.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/group/PutName.java b/java/com/google/gerrit/server/restapi/group/PutName.java
index 9a3c87d..acdae33 100644
--- a/java/com/google/gerrit/server/restapi/group/PutName.java
+++ b/java/com/google/gerrit/server/restapi/group/PutName.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.NameInput;
diff --git a/java/com/google/gerrit/server/restapi/group/PutOptions.java b/java/com/google/gerrit/server/restapi/group/PutOptions.java
index 53bf571..748861e 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOptions.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOptions.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.restapi.group;
 
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/group/PutOwner.java b/java/com/google/gerrit/server/restapi/group/PutOwner.java
index 04129af..96ce9e4 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOwner.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.groups.OwnerInput;
 import com.google.gerrit.extensions.common.GroupInfo;
diff --git a/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
index cebc27a..c7f6473 100644
--- a/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.group;
 
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
index 5deace9..783b39b 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
@@ -77,8 +77,7 @@
     this.defaultSubmitType.value = projectState.getSubmitType();
     this.defaultSubmitType.configuredValue =
         MoreObjects.firstNonNull(
-            projectState.getConfig().getProject().getConfiguredSubmitType(),
-            Project.DEFAULT_SUBMIT_TYPE);
+            projectState.getConfig().getProject().getSubmitType(), Project.DEFAULT_SUBMIT_TYPE);
     ProjectState parent =
         projectState.isAllProjects() ? projectState : projectState.parents().get(0);
     this.defaultSubmitType.inheritedValue = parent.getSubmitType();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index a87bbd1..eceab43 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -17,7 +17,7 @@
 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.AccessSection;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
new file mode 100644
index 0000000..dbcd8c9
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -0,0 +1,69 @@
+// 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.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+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.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+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 CreateChange implements RestModifyView<ProjectResource, ChangeInput> {
+  private final com.google.gerrit.server.restapi.change.CreateChange changeCreateChange;
+  private final Provider<CurrentUser> user;
+  private final BatchUpdate.Factory updateFactory;
+
+  @Inject
+  public CreateChange(
+      Provider<CurrentUser> user,
+      BatchUpdate.Factory updateFactory,
+      com.google.gerrit.server.restapi.change.CreateChange changeCreateChange) {
+    this.updateFactory = updateFactory;
+    this.changeCreateChange = changeCreateChange;
+    this.user = user;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, ChangeInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException,
+          InvalidChangeOperationException, InvalidNameException, UpdateException, RestApiException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    if (!Strings.isNullOrEmpty(input.project)) {
+      throw new BadRequestException("may not specify project");
+    }
+
+    input.project = rsrc.getName();
+    return changeCreateChange.execute(updateFactory, input, rsrc);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 51cbcea..e93c25c 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -15,9 +15,9 @@
 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.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -134,15 +134,15 @@
       throw new BadRequestException("values are required");
     }
 
-    List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
-
-    LabelType labelType;
     try {
-      labelType = new LabelType(label, values);
+      LabelType.checkName(label);
     } catch (IllegalArgumentException e) {
       throw new BadRequestException("invalid name: " + label, e);
     }
 
+    List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
+    LabelType.Builder labelType = LabelType.builder(LabelType.checkName(label), values);
+
     if (input.function != null && !input.function.trim().isEmpty()) {
       labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
     } else {
@@ -203,8 +203,9 @@
       labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
     }
 
-    config.getLabelSections().put(labelType.getName(), labelType);
+    LabelType lt = labelType.build();
+    config.upsertLabelType(lt);
 
-    return labelType;
+    return lt;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index 0432150..651e7f0 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -26,11 +26,11 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
@@ -157,7 +157,7 @@
         projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
       } else if (config.getRevision() != null
-          && !config.getRevision().equals(projectState.getConfig().getRevision())) {
+          && !config.getRevision().equals(projectState.getConfig().getRevision().orElse(null))) {
         projectCache.evictAndReindex(config.getProject());
         projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
@@ -208,9 +208,9 @@
           // user is a member of, as well as groups they own or that
           // are visible to all users.
 
-          AccessSection dst = null;
+          AccessSection.Builder dst = null;
           for (Permission srcPerm : section.getPermissions()) {
-            Permission dstPerm = null;
+            Permission.Builder dstPerm = null;
 
             for (PermissionRule srcRule : srcPerm.getRules()) {
               AccountGroup.UUID groupId = srcRule.getGroup().getUUID();
@@ -221,12 +221,12 @@
               loadGroup(groups, groupId);
               if (dstPerm == null) {
                 if (dst == null) {
-                  dst = new AccessSection(name);
-                  info.local.put(name, createAccessSection(groups, dst));
+                  dst = AccessSection.builder(name);
+                  info.local.put(name, createAccessSection(groups, dst.build()));
                 }
-                dstPerm = dst.getPermission(srcPerm.getName(), true);
+                dstPerm = dst.upsertPermission(srcPerm.getName());
               }
-              dstPerm.add(srcRule);
+              dstPerm.add(srcRule.toBuilder());
             }
           }
         }
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index ce45e7d..ad66587 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -24,6 +24,9 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
+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.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -34,6 +37,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
+  private final PermissionBackend permissionBackend;
   private final UiActions uiActions;
   private final DynamicMap<RestView<ProjectResource>> views;
 
@@ -43,24 +47,31 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      PermissionBackend permissionBackend,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.pluginConfigEntries = pluginConfigEntries;
     this.allProjects = allProjects;
     this.cfgFactory = cfgFactory;
+    this.permissionBackend = permissionBackend;
     this.uiActions = uiActions;
     this.views = views;
   }
 
   @Override
-  public Response<ConfigInfo> apply(ProjectResource resource) {
+  public Response<ConfigInfo> apply(ProjectResource resource) throws PermissionBackendException {
+    boolean readConfigAllowed =
+        permissionBackend
+            .currentUser()
+            .project(resource.getNameKey())
+            .test(ProjectPermission.READ_CONFIG);
     return Response.ok(
         new ConfigInfoImpl(
             serverEnableSignedPush,
             resource.getProjectState(),
             resource.getUser(),
-            pluginConfigEntries,
+            readConfigAllowed ? pluginConfigEntries : DynamicMap.emptyMap(),
             cfgFactory,
             allProjects,
             uiActions,
diff --git a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
index 1e288f4..0f49e63 100644
--- a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
+++ b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.common.collect.ImmutableList;
 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.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -56,21 +57,22 @@
       if (valueDescription.isEmpty()) {
         throw new BadRequestException("description for value '" + e.getKey() + "' cannot be empty");
       }
-      valueList.add(new LabelValue(value, valueDescription));
+      valueList.add(LabelValue.create(value, valueDescription));
     }
     return valueList;
   }
 
-  public static short parseDefaultValue(LabelType labelType, short defaultValue)
+  public static short parseDefaultValue(LabelType.Builder labelType, short defaultValue)
       throws BadRequestException {
-    if (labelType.getValue(defaultValue) == null) {
+    if (!labelType.getValues().stream().anyMatch(v -> v.getValue() == defaultValue)) {
       throw new BadRequestException("invalid default value: " + defaultValue);
     }
     return defaultValue;
   }
 
-  public static List<String> parseBranches(List<String> branches) throws BadRequestException {
-    List<String> validBranches = new ArrayList<>();
+  public static ImmutableList<String> parseBranches(List<String> branches)
+      throws BadRequestException {
+    ImmutableList.Builder<String> validBranches = ImmutableList.builder();
     for (String branch : branches) {
       String newBranch = branch.trim();
       if (newBranch.isEmpty()) {
@@ -86,7 +88,7 @@
       }
       validBranches.add(newBranch);
     }
-    return validBranches;
+    return validBranches.build();
   }
 
   private LabelDefinitionInputParser() {}
diff --git a/java/com/google/gerrit/server/restapi/project/LabelsCollection.java b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
index 0409729..54179e5 100644
--- a/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
diff --git a/java/com/google/gerrit/server/restapi/project/ListLabels.java b/java/com/google/gerrit/server/restapi/project/ListLabels.java
index 19a8915..56ee4cd 100644
--- a/java/com/google/gerrit/server/restapi/project/ListLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/ListLabels.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index c56e8c6..5418876 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -26,8 +26,8 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.NoSuchGroupException;
@@ -71,6 +71,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
@@ -434,8 +435,9 @@
     PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
     final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
     try {
-      Iterable<ProjectState> projectStatesIt = filter(perm)::iterator;
-      for (ProjectState e : projectStatesIt) {
+      Iterator<ProjectState> projectStatesIt = filter(perm).iterator();
+      while (projectStatesIt.hasNext()) {
+        ProjectState e = projectStatesIt.next();
         Project.NameKey projectName = e.getNameKey();
         if (e.getProject().getState() == HIDDEN && !all && state != HIDDEN) {
           // If we can't get it from the cache, pretend it's not present.
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index 5b3ea30..ee3914d 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -86,6 +86,7 @@
 
     child(PROJECT_KIND, "branches").to(BranchesCollection.class);
     create(BRANCH_KIND).to(CreateBranch.class);
+    post(PROJECT_KIND, "create.change").to(CreateChange.class);
     put(BRANCH_KIND).to(PutBranch.class);
     get(BRANCH_KIND).to(GetBranch.class);
     delete(BRANCH_KIND).to(DeleteBranch.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index 69e31b4..b72e10f 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 1d5c151..3bf432b 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -41,7 +41,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.EnableSignedPush;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
@@ -134,28 +133,25 @@
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      Project p = projectConfig.getProject();
-
-      p.setDescription(Strings.emptyToNull(input.description));
-
-      for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
-        InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
-        if (val != null) {
-          p.setBooleanConfig(cfg, val);
-        }
-      }
-
-      if (input.maxObjectSizeLimit != null) {
-        p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
-      }
-
-      if (input.submitType != null) {
-        p.setSubmitType(input.submitType);
-      }
-
-      if (input.state != null) {
-        p.setState(input.state);
-      }
+      projectConfig.updateProject(
+          p -> {
+            p.setDescription(Strings.emptyToNull(input.description));
+            for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
+              InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
+              if (val != null) {
+                p.setBooleanConfig(cfg, val);
+              }
+            }
+            if (input.maxObjectSizeLimit != null) {
+              p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
+            }
+            if (input.submitType != null) {
+              p.setSubmitType(input.submitType);
+            }
+            if (input.state != null) {
+              p.setState(input.state);
+            }
+          });
 
       if (input.pluginConfigValues != null) {
         setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
@@ -169,7 +165,7 @@
       try {
         projectConfig.commit(md);
         projectCache.evictAndReindex(projectConfig.getProject());
-        md.getRepository().setGitwebDescription(p.getDescription());
+        md.getRepository().setGitwebDescription(projectConfig.getProject().getDescription());
       } catch (IOException e) {
         if (e.getCause() instanceof ConfigInvalidException) {
           throw new ResourceConflictException(
@@ -179,7 +175,7 @@
         throw new ResourceConflictException("Cannot update " + projectName);
       }
 
-      ProjectState state = projectStateFactory.create(projectConfigFactory.read(md));
+      ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
       return new ConfigInfoImpl(
           serverEnableSignedPush,
           state,
@@ -205,7 +201,6 @@
       throws BadRequestException {
     for (Map.Entry<String, Map<String, ConfigValue>> e : pluginConfigValues.entrySet()) {
       String pluginName = e.getKey();
-      PluginConfig cfg = projectConfig.getPluginConfig(pluginName);
       for (Map.Entry<String, ConfigValue> v : e.getValue().entrySet()) {
         ProjectConfigEntry projectConfigEntry = pluginConfigEntries.get(pluginName, v.getKey());
         if (projectConfigEntry != null) {
@@ -216,10 +211,11 @@
                 v.getKey(), PARAMETER_NAME_PATTERN.pattern());
             continue;
           }
-          String oldValue = cfg.getString(v.getKey());
+          String oldValue = projectConfig.getPluginConfig(pluginName).getString(v.getKey());
           String value = v.getValue().value;
           if (projectConfigEntry.getType() == ProjectConfigEntryType.ARRAY) {
-            List<String> l = Arrays.asList(cfg.getStringList(v.getKey()));
+            List<String> l =
+                Arrays.asList(projectConfig.getPluginConfig(pluginName).getStringList(v.getKey()));
             oldValue = Joiner.on("\n").join(l);
             value = Joiner.on("\n").join(v.getValue().values);
           }
@@ -233,15 +229,18 @@
                 switch (projectConfigEntry.getType()) {
                   case BOOLEAN:
                     boolean newBooleanValue = Boolean.parseBoolean(value);
-                    cfg.setBoolean(v.getKey(), newBooleanValue);
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setBoolean(v.getKey(), newBooleanValue));
                     break;
                   case INT:
                     int newIntValue = Integer.parseInt(value);
-                    cfg.setInt(v.getKey(), newIntValue);
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setInt(v.getKey(), newIntValue));
                     break;
                   case LONG:
                     long newLongValue = Long.parseLong(value);
-                    cfg.setLong(v.getKey(), newLongValue);
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setLong(v.getKey(), newLongValue));
                     break;
                   case LIST:
                     if (!projectConfigEntry.getPermittedValues().contains(value)) {
@@ -255,10 +254,13 @@
                     }
                     // $FALL-THROUGH$
                   case STRING:
-                    cfg.setString(v.getKey(), value);
+                    String valueToSet = value;
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setString(v.getKey(), valueToSet));
                     break;
                   case ARRAY:
-                    cfg.setStringList(v.getKey(), v.getValue().values);
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setStringList(v.getKey(), v.getValue().values));
                     break;
                   default:
                     logger.atWarning().log(
@@ -276,7 +278,7 @@
             if (oldValue != null) {
               validateProjectConfigEntryIsEditable(
                   projectConfigEntry, projectState, v.getKey(), pluginName);
-              cfg.unset(v.getKey());
+              projectConfig.updatePluginConfig(pluginName, cfg -> cfg.unset(v.getKey()));
             }
           }
         } else {
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index 3acbb61..ec42035 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -73,8 +72,8 @@
 
     try (MetaDataUpdate md = updateFactory.get().create(resource.getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
-      Project project = config.getProject();
-      project.setDescription(Strings.emptyToNull(input.description));
+      String desc = input.description;
+      config.updateProject(p -> p.setDescription(Strings.emptyToNull(desc)));
 
       String msg =
           MoreObjects.firstNonNull(
@@ -86,11 +85,11 @@
       md.setMessage(msg);
       config.commit(md);
       cache.evictAndReindex(resource.getProjectState().getProject());
-      md.getRepository().setGitwebDescription(project.getDescription());
+      md.getRepository().setGitwebDescription(config.getProject().getDescription());
 
-      return Strings.isNullOrEmpty(project.getDescription())
+      return Strings.isNullOrEmpty(config.getProject().getDescription())
           ? Response.none()
-          : Response.ok(project.getDescription());
+          : Response.ok(config.getProject().getDescription());
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(resource.getName(), notFound);
     } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index c3a332b..07dbeca 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -16,7 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 5d5e779..65cc5a2 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
 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.AccessSection;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
@@ -78,14 +78,14 @@
         continue;
       }
 
-      AccessSection accessSection = new AccessSection(entry.getKey());
+      AccessSection.Builder accessSection = AccessSection.builder(entry.getKey());
       for (Map.Entry<String, PermissionInfo> permissionEntry :
           entry.getValue().permissions.entrySet()) {
         if (permissionEntry.getValue().rules == null) {
           continue;
         }
 
-        Permission p = new Permission(permissionEntry.getKey());
+        Permission.Builder p = Permission.builder(permissionEntry.getKey());
         if (permissionEntry.getValue().exclusive != null) {
           p.setExclusiveGroup(permissionEntry.getValue().exclusive);
         }
@@ -99,7 +99,7 @@
           }
 
           PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
-          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
+          PermissionRule.Builder r = PermissionRule.builder(GroupReference.forGroup(group));
           if (pri != null) {
             if (pri.max != null) {
               r.setMax(pri.max);
@@ -118,7 +118,7 @@
         }
         accessSection.addPermission(p);
       }
-      sections.add(accessSection);
+      sections.add(accessSection.build());
     }
     return sections;
   }
@@ -193,25 +193,23 @@
 
     // Apply additions
     for (AccessSection section : additions) {
-      AccessSection currentAccessSection = config.getAccessSection(section.getName());
-
-      if (currentAccessSection == null) {
-        // Add AccessSection
-        config.replace(section);
-      } else {
-        for (Permission p : section.getPermissions()) {
-          Permission currentPermission = currentAccessSection.getPermission(p.getName());
-          if (currentPermission == null) {
-            // Add Permission
-            currentAccessSection.addPermission(p);
-          } else {
-            for (PermissionRule r : p.getRules()) {
-              // AddPermissionRule
-              currentPermission.add(r);
+      config.upsertAccessSection(
+          section.getName(),
+          existingAccessSection -> {
+            for (Permission p : section.getPermissions()) {
+              Permission currentPermission =
+                  existingAccessSection.build().getPermission(p.getName());
+              if (currentPermission == null) {
+                // Add Permission
+                existingAccessSection.addPermission(p.toBuilder());
+              } else {
+                for (PermissionRule r : p.getRules()) {
+                  // AddPermissionRule
+                  existingAccessSection.upsertPermission(p.getName()).add(r.toBuilder());
+                }
+              }
             }
-          }
-        }
-      }
+          });
     }
   }
 
@@ -243,7 +241,7 @@
       } catch (UnprocessableEntityException e) {
         throw new ResourceConflictException(e.getMessage(), e);
       }
-      config.getProject().setParentName(newParentProjectName);
+      config.updateProject(p -> p.setParent(newParentProjectName));
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index b105442..853d7df 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -97,11 +96,11 @@
 
     try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
-      Project project = config.getProject();
+      String id = input.id;
       if (inherited) {
-        project.setDefaultDashboard(input.id);
+        config.updateProject(p -> p.setDefaultDashboard(id));
       } else {
-        project.setLocalDefaultDashboard(input.id);
+        config.updateProject(p -> p.setLocalDefaultDashboard(id));
       }
 
       String msg =
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 13a873c..1e04dc4 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -88,6 +88,9 @@
         } else {
           md.setMessage("Update label");
         }
+        String newName = Strings.nullToEmpty(input.name).trim();
+        labelType =
+            config.getLabelSections().get(newName.isEmpty() ? labelType.getName() : newName);
 
         config.commit(md);
         projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
@@ -109,8 +112,7 @@
   public boolean updateLabel(ProjectConfig config, LabelType labelType, LabelDefinitionInput input)
       throws BadRequestException, ResourceConflictException {
     boolean dirty = false;
-
-    config.getLabelSections().remove(labelType.getName());
+    LabelType.Builder labelTypeBuilder = labelType.toBuilder();
 
     if (input.name != null) {
       String newName = input.name.trim();
@@ -130,10 +132,12 @@
         }
 
         try {
-          labelType.setName(newName);
+          LabelType.checkName(newName);
         } catch (IllegalArgumentException e) {
           throw new BadRequestException("invalid name: " + input.name, e);
         }
+
+        labelTypeBuilder.setName(newName);
         dirty = true;
       }
     }
@@ -142,7 +146,7 @@
       if (input.function.trim().isEmpty()) {
         throw new BadRequestException("function cannot be empty");
       }
-      labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+      labelTypeBuilder.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
       dirty = true;
     }
 
@@ -150,77 +154,79 @@
       if (input.values.isEmpty()) {
         throw new BadRequestException("values cannot be empty");
       }
-      labelType.setValues(LabelDefinitionInputParser.parseValues(input.values));
+      labelTypeBuilder.setValues(LabelDefinitionInputParser.parseValues(input.values));
       dirty = true;
     }
 
     if (input.defaultValue != null) {
-      labelType.setDefaultValue(
-          LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+      labelTypeBuilder.setDefaultValue(
+          LabelDefinitionInputParser.parseDefaultValue(labelTypeBuilder, input.defaultValue));
       dirty = true;
     }
 
     if (input.branches != null) {
-      labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+      labelTypeBuilder.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
       dirty = true;
     }
 
     if (input.canOverride != null) {
-      labelType.setCanOverride(input.canOverride);
+      labelTypeBuilder.setCanOverride(input.canOverride);
       dirty = true;
     }
 
     if (input.copyAnyScore != null) {
-      labelType.setCopyAnyScore(input.copyAnyScore);
+      labelTypeBuilder.setCopyAnyScore(input.copyAnyScore);
       dirty = true;
     }
 
     if (input.copyMinScore != null) {
-      labelType.setCopyMinScore(input.copyMinScore);
+      labelTypeBuilder.setCopyMinScore(input.copyMinScore);
       dirty = true;
     }
 
     if (input.copyMaxScore != null) {
-      labelType.setCopyMaxScore(input.copyMaxScore);
+      labelTypeBuilder.setCopyMaxScore(input.copyMaxScore);
       dirty = true;
     }
 
     if (input.copyAllScoresIfNoChange != null) {
-      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+      labelTypeBuilder.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+      dirty = true;
     }
 
     if (input.copyAllScoresIfNoCodeChange != null) {
-      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+      labelTypeBuilder.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
       dirty = true;
     }
 
     if (input.copyAllScoresOnTrivialRebase != null) {
-      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+      labelTypeBuilder.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
       dirty = true;
     }
 
     if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+      labelTypeBuilder.setCopyAllScoresOnMergeFirstParentUpdate(
           input.copyAllScoresOnMergeFirstParentUpdate);
       dirty = true;
     }
 
     if (input.copyValues != null) {
-      labelType.setCopyValues(input.copyValues);
+      labelTypeBuilder.setCopyValues(input.copyValues);
       dirty = true;
     }
 
     if (input.allowPostSubmit != null) {
-      labelType.setAllowPostSubmit(input.allowPostSubmit);
+      labelTypeBuilder.setAllowPostSubmit(input.allowPostSubmit);
       dirty = true;
     }
 
     if (input.ignoreSelfApproval != null) {
-      labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+      labelTypeBuilder.setIgnoreSelfApproval(input.ignoreSelfApproval);
       dirty = true;
     }
 
-    config.getLabelSections().put(labelType.getName(), labelType);
+    config.getLabelSections().remove(labelType.getName());
+    config.upsertLabelType(labelTypeBuilder.build());
 
     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 13564c1..ef31dc5 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -103,8 +103,7 @@
     validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
     try (MetaDataUpdate md = updateFactory.get().create(rsrc.getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
-      Project project = config.getProject();
-      project.setParentName(parentName);
+      config.updateProject(p -> p.setParent(parentName));
 
       String msg = Strings.emptyToNull(input.commitMessage);
       if (msg == null) {
@@ -117,7 +116,7 @@
       config.commit(md);
       cache.evictAndReindex(rsrc.getProjectState().getProject());
 
-      Project.NameKey parent = project.getParent(allProjects);
+      Project.NameKey parent = config.getProject().getParent(allProjects);
       requireNonNull(parent);
       return parent.get();
     } catch (RepositoryNotFoundException notFound) {
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 799d706..4592100 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -18,10 +18,10 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index 54915fb..b2bfbd5 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -18,12 +18,12 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-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.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -66,7 +66,8 @@
       return ruleError(E_UNABLE_TO_FETCH_LABELS);
     }
 
-    boolean shouldIgnoreSelfApproval = labelTypes.stream().anyMatch(LabelType::ignoreSelfApproval);
+    boolean shouldIgnoreSelfApproval =
+        labelTypes.stream().anyMatch(LabelType::isIgnoreSelfApproval);
     if (!shouldIgnoreSelfApproval) {
       // Shortcut to avoid further processing if no label should ignore uploader approvals
       return Optional.empty();
@@ -86,7 +87,7 @@
     submitRecord.requirements = new ArrayList<>();
 
     for (LabelType t : labelTypes) {
-      if (!t.ignoreSelfApproval()) {
+      if (!t.isIgnoreSelfApproval()) {
         // The default rules are enough in this case.
         continue;
       }
diff --git a/java/com/google/gerrit/server/rules/PrologRule.java b/java/com/google/gerrit/server/rules/PrologRule.java
index 1861ee7..8f17fa1 100644
--- a/java/com/google/gerrit/server/rules/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/PrologRule.java
@@ -16,8 +16,8 @@
 
 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.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 5f1268b..57c4832 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -23,11 +23,11 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-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.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.server.account.AccountCache;
@@ -453,7 +453,7 @@
       } else {
         pmc =
             rulesCache.loadMachine(
-                projectState.getNameKey(), projectState.getConfig().getRulesId());
+                projectState.getNameKey(), projectState.getConfig().getRulesId().orElse(null));
       }
       env = envFactory.create(pmc);
     } catch (CompileException err) {
@@ -490,7 +490,7 @@
         parentEnv =
             envFactory.create(
                 rulesCache.loadMachine(
-                    parentState.getNameKey(), parentState.getConfig().getRulesId()));
+                    parentState.getNameKey(), parentState.getConfig().getRulesId().orElse(null)));
       } catch (CompileException err) {
         throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
       }
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index ed67e68..8b72714 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -55,6 +55,7 @@
 import java.util.EnumSet;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -128,8 +129,8 @@
    * @return a Prolog machine, after loading the specified rules.
    * @throws CompileException the machine cannot be created.
    */
-  public synchronized PrologMachineCopy loadMachine(Project.NameKey project, ObjectId rulesId)
-      throws CompileException {
+  public synchronized PrologMachineCopy loadMachine(
+      @Nullable Project.NameKey project, @Nullable ObjectId rulesId) throws CompileException {
     if (!enableProjectRules || project == null || rulesId == null) {
       return defaultMachine;
     }
diff --git a/java/com/google/gerrit/server/rules/SubmitRule.java b/java/com/google/gerrit/server/rules/SubmitRule.java
index b221117..90d2137 100644
--- a/java/com/google/gerrit/server/rules/SubmitRule.java
+++ b/java/com/google/gerrit/server/rules/SubmitRule.java
@@ -13,7 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.server.rules;
 
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/schema/AclUtil.java b/java/com/google/gerrit/server/schema/AclUtil.java
index f6c3aad..911756b 100644
--- a/java/com/google/gerrit/server/schema/AclUtil.java
+++ b/java/com/google/gerrit/server/schema/AclUtil.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.gerrit.common.data.AccessSection;
-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.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.server.project.ProjectConfig;
 
 /**
@@ -27,13 +27,16 @@
  */
 public class AclUtil {
   public static void grant(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
+      ProjectConfig config,
+      AccessSection.Builder section,
+      String permission,
+      GroupReference... groupList) {
     grant(config, section, permission, false, groupList);
   }
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       String permission,
       boolean force,
       GroupReference... groupList) {
@@ -42,39 +45,38 @@
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       String permission,
       boolean force,
       Boolean exclusive,
       GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
+    Permission.Builder p = section.upsertPermission(permission);
     if (exclusive != null) {
       p.setExclusiveGroup(exclusive);
     }
     for (GroupReference group : groupList) {
       if (group != null) {
-        PermissionRule r = rule(config, group);
-        r.setForce(force);
-        p.add(r);
+        p.add(rule(config, group).setForce(force));
       }
     }
   }
 
   public static void block(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
+      ProjectConfig config,
+      AccessSection.Builder section,
+      String permission,
+      GroupReference... groupList) {
+    Permission.Builder p = section.upsertPermission(permission);
     for (GroupReference group : groupList) {
       if (group != null) {
-        PermissionRule r = rule(config, group);
-        r.setBlock();
-        p.add(r);
+        p.add(rule(config, group).setBlock());
       }
     }
   }
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       LabelType type,
       int min,
       int max,
@@ -84,35 +86,35 @@
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       LabelType type,
       int min,
       int max,
       boolean exclusive,
       GroupReference... groupList) {
     String name = Permission.LABEL + type.getName();
-    Permission p = section.getPermission(name, true);
+    Permission.Builder p = section.upsertPermission(name);
     p.setExclusiveGroup(exclusive);
     for (GroupReference group : groupList) {
       if (group != null) {
-        PermissionRule r = rule(config, group);
-        r.setRange(min, max);
-        p.add(r);
+        p.add(rule(config, group).setRange(min, max));
       }
     }
   }
 
-  public static PermissionRule rule(ProjectConfig config, GroupReference group) {
-    return new PermissionRule(config.resolve(group));
+  public static PermissionRule.Builder rule(ProjectConfig config, GroupReference group) {
+    return PermissionRule.builder(config.resolve(group));
   }
 
   public static void remove(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
+      ProjectConfig config,
+      AccessSection.Builder section,
+      String permission,
+      GroupReference... groupList) {
+    Permission.Builder p = section.upsertPermission(permission);
     for (GroupReference group : groupList) {
       if (group != null) {
-        PermissionRule r = rule(config, group);
-        p.remove(r);
+        p.remove(rule(config, group).build());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index cfa5825..6faaec5 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -23,14 +23,12 @@
 import static com.google.gerrit.server.schema.AclUtil.rule;
 
 import com.google.gerrit.common.Version;
-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.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -116,19 +114,16 @@
 
       // init basic project configs.
       ProjectConfig config = projectConfigFactory.read(md);
-      Project p = config.getProject();
-      p.setDescription(
-          input.projectDescription().orElse("Access inherited by all other projects."));
-
-      // init boolean project configs.
-      input.booleanProjectConfigs().forEach(p::setBooleanConfig);
+      config.updateProject(
+          p -> {
+            p.setDescription(
+                input.projectDescription().orElse("Access inherited by all other projects."));
+            // init boolean project configs.
+            input.booleanProjectConfigs().forEach(p::setBooleanConfig);
+          });
 
       // init labels.
-      input
-          .codeReviewLabel()
-          .ifPresent(
-              codeReviewLabel ->
-                  config.getLabelSections().put(codeReviewLabel.getName(), codeReviewLabel));
+      input.codeReviewLabel().ifPresent(codeReviewLabel -> config.upsertLabelType(codeReviewLabel));
 
       if (input.initDefaultAcls()) {
         // init access sections.
@@ -149,81 +144,107 @@
   }
 
   private void initDefaultAcls(ProjectConfig config, AllProjectsInput input) {
-    AccessSection capabilities = config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true);
-    AccessSection heads = config.getAccessSection(AccessSection.HEADS, true);
-
     checkArgument(input.codeReviewLabel().isPresent());
     LabelType codeReviewLabel = input.codeReviewLabel().get();
 
-    initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+    config.upsertAccessSection(
+        AccessSection.HEADS,
+        heads -> {
+          initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+        });
 
-    input
-        .batchUsersGroup()
-        .ifPresent(
-            batchUsersGroup -> initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
+    config.upsertAccessSection(
+        AccessSection.GLOBAL_CAPABILITIES,
+        capabilities -> {
+          input
+              .serviceUsersGroup()
+              .ifPresent(
+                  batchUsersGroup ->
+                      initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
+        });
 
     input
         .administratorsGroup()
-        .ifPresent(
-            adminsGroup ->
-                initDefaultAclsForAdmins(
-                    capabilities, config, heads, codeReviewLabel, adminsGroup));
+        .ifPresent(adminsGroup -> initDefaultAclsForAdmins(config, codeReviewLabel, adminsGroup));
   }
 
   private void initDefaultAclsForRegisteredUsers(
-      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);
+      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
+    config.upsertAccessSection(
+        "refs/for/*",
+        refsFor -> {
+          grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+        });
 
-    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);
+
+    config.upsertAccessSection(
+        "refs/*",
+        all -> {
+          grant(config, all, Permission.REVERT, registered);
+        });
+
+    config.upsertAccessSection(
+        "refs/for/" + AccessSection.ALL,
+        magic -> {
+          grant(config, magic, Permission.PUSH, registered);
+          grant(config, magic, Permission.PUSH_MERGE, registered);
+        });
   }
 
   private void initDefaultAclsForBatchUsers(
-      AccessSection capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
-    Permission priority = capabilities.getPermission(GlobalCapability.PRIORITY, true);
-    PermissionRule r = rule(config, batchUsersGroup);
-    r.setAction(Action.BATCH);
-    priority.add(r);
+      AccessSection.Builder capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
+    Permission.Builder priority = capabilities.upsertPermission(GlobalCapability.PRIORITY);
+    priority.add(rule(config, batchUsersGroup).setAction(Action.BATCH));
 
-    Permission stream = capabilities.getPermission(GlobalCapability.STREAM_EVENTS, true);
+    Permission.Builder stream = capabilities.upsertPermission(GlobalCapability.STREAM_EVENTS);
     stream.add(rule(config, batchUsersGroup));
   }
 
   private void initDefaultAclsForAdmins(
-      AccessSection capabilities,
-      ProjectConfig config,
-      AccessSection heads,
-      LabelType codeReviewLabel,
-      GroupReference adminsGroup) {
-    AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-    AccessSection tags = config.getAccessSection("refs/tags/*", true);
-    AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
+      ProjectConfig config, LabelType codeReviewLabel, GroupReference adminsGroup) {
+    config.upsertAccessSection(
+        AccessSection.GLOBAL_CAPABILITIES,
+        capabilities -> {
+          grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
+        });
 
-    grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
-    grant(config, all, Permission.READ, adminsGroup, anonymous);
-    grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
-    grant(config, heads, Permission.CREATE, adminsGroup, owners);
-    grant(config, heads, Permission.PUSH, adminsGroup, owners);
-    grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
-    grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
-    grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+    config.upsertAccessSection(
+        AccessSection.ALL,
+        all -> {
+          grant(config, all, Permission.READ, adminsGroup, anonymous);
+        });
 
-    grant(config, tags, Permission.CREATE, adminsGroup, owners);
-    grant(config, tags, Permission.CREATE_TAG, adminsGroup, owners);
-    grant(config, tags, Permission.CREATE_SIGNED_TAG, adminsGroup, owners);
+    config.upsertAccessSection(
+        AccessSection.HEADS,
+        heads -> {
+          grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
+          grant(config, heads, Permission.CREATE, adminsGroup, owners);
+          grant(config, heads, Permission.PUSH, adminsGroup, owners);
+          grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
+          grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
+          grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+        });
 
-    meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
-    grant(config, meta, Permission.READ, adminsGroup, owners);
-    grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
-    grant(config, meta, Permission.CREATE, adminsGroup, owners);
-    grant(config, meta, Permission.PUSH, adminsGroup, owners);
-    grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
+    config.upsertAccessSection(
+        "refs/tags/*",
+        tags -> {
+          grant(config, tags, Permission.CREATE, adminsGroup, owners);
+          grant(config, tags, Permission.CREATE_TAG, adminsGroup, owners);
+          grant(config, tags, Permission.CREATE_SIGNED_TAG, adminsGroup, owners);
+        });
+
+    config.upsertAccessSection(
+        RefNames.REFS_CONFIG,
+        meta -> {
+          meta.upsertPermission(Permission.READ).setExclusiveGroup(true);
+          grant(config, meta, Permission.READ, adminsGroup, owners);
+          grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
+          grant(config, meta, Permission.CREATE, adminsGroup, owners);
+          grant(config, meta, Permission.PUSH, adminsGroup, owners);
+          grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
+        });
   }
 
   private void initSequences(Repository git, BatchRefUpdate bru, int firstChangeId)
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 6e11a5d..daa24d8 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -18,10 +18,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.notedb.Sequences;
 import java.util.Optional;
@@ -46,25 +46,24 @@
 
   @UsedAt(UsedAt.Project.GOOGLE)
   public static LabelType getDefaultCodeReviewLabel() {
-    LabelType type =
-        new LabelType(
+    return LabelType.builder(
             "Code-Review",
             ImmutableList.of(
-                new LabelValue((short) 2, "Looks good to me, approved"),
-                new LabelValue((short) 1, "Looks good to me, but someone else must approve"),
-                new LabelValue((short) 0, "No score"),
-                new LabelValue((short) -1, "I would prefer this is not merged as is"),
-                new LabelValue((short) -2, "This shall not be merged")));
-    type.setCopyMinScore(true);
-    type.setCopyAllScoresOnTrivialRebase(true);
-    return type;
+                LabelValue.create((short) 2, "Looks good to me, approved"),
+                LabelValue.create((short) 1, "Looks good to me, but someone else must approve"),
+                LabelValue.create((short) 0, "No score"),
+                LabelValue.create((short) -1, "I would prefer this is not merged as is"),
+                LabelValue.create((short) -2, "This shall not be merged")))
+        .setCopyMinScore(true)
+        .setCopyAllScoresOnTrivialRebase(true)
+        .build();
   }
 
   /** The administrator group which gets default permissions granted. */
   public abstract Optional<GroupReference> administratorsGroup();
 
   /** The group which gets stream-events permission granted and appropriate properties set. */
-  public abstract Optional<GroupReference> batchUsersGroup();
+  public abstract Optional<GroupReference> serviceUsersGroup();
 
   /** The commit message used when commit the project config change. */
   public abstract Optional<String> commitMessage();
@@ -107,7 +106,7 @@
   public abstract static class Builder {
     public abstract Builder administratorsGroup(GroupReference adminGroup);
 
-    public abstract Builder batchUsersGroup(GroupReference batchGroup);
+    public abstract Builder serviceUsersGroup(GroupReference serviceGroup);
 
     public abstract Builder commitMessage(String commitMessage);
 
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 4904028..90973fb 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -22,11 +22,9 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.Version;
-import com.google.gerrit.common.data.AccessSection;
-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.entities.Project;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
@@ -112,36 +110,41 @@
       md.setMessage("Initialized Gerrit Code Review " + Version.getVersion());
 
       ProjectConfig config = projectConfigFactory.read(md);
-      Project project = config.getProject();
-      project.setDescription("Individual user settings and preferences.");
+      config.updateProject(p -> p.setDescription("Individual user settings and preferences."));
 
-      AccessSection users =
-          config.getAccessSection(
-              RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
+      config.upsertAccessSection(
+          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+          users -> {
+            grant(config, users, Permission.READ, false, true, registered);
+            grant(config, users, Permission.PUSH, false, true, registered);
+            grant(config, users, Permission.SUBMIT, false, true, registered);
+            grant(config, users, codeReviewLabel, -2, 2, true, registered);
+          });
 
       // Initialize "Code-Review" label.
-      config.getLabelSections().put(codeReviewLabel.getName(), codeReviewLabel);
-
-      grant(config, users, Permission.READ, false, true, registered);
-      grant(config, users, Permission.PUSH, false, true, registered);
-      grant(config, users, Permission.SUBMIT, false, true, registered);
-      grant(config, users, codeReviewLabel, -2, 2, true, registered);
+      config.upsertLabelType(codeReviewLabel);
 
       if (admin != null) {
-        AccessSection defaults = config.getAccessSection(RefNames.REFS_USERS_DEFAULT, true);
-        defaults.getPermission(Permission.READ, true).setExclusiveGroup(true);
-        grant(config, defaults, Permission.READ, admin);
-        defaults.getPermission(Permission.PUSH, true).setExclusiveGroup(true);
-        grant(config, defaults, Permission.PUSH, admin);
-        defaults.getPermission(Permission.CREATE, true).setExclusiveGroup(true);
-        grant(config, defaults, Permission.CREATE, admin);
+        config.upsertAccessSection(
+            RefNames.REFS_USERS_DEFAULT,
+            defaults -> {
+              defaults.upsertPermission(Permission.READ).setExclusiveGroup(true);
+              grant(config, defaults, Permission.READ, admin);
+              defaults.upsertPermission(Permission.PUSH).setExclusiveGroup(true);
+              grant(config, defaults, Permission.PUSH, admin);
+              defaults.upsertPermission(Permission.CREATE).setExclusiveGroup(true);
+              grant(config, defaults, Permission.CREATE, admin);
+            });
       }
 
       // Grant read permissions on the group branches to all users.
       // This allows group owners to see the group refs. VisibleRefFilter ensures that read
       // permissions for non-group-owners are ignored.
-      AccessSection groups = config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
-      grant(config, groups, Permission.READ, false, true, registered);
+      config.upsertAccessSection(
+          RefNames.REFS_GROUPS + "*",
+          groups -> {
+            grant(config, groups, Permission.READ, false, true, registered);
+          });
 
       config.commit(md);
     }
diff --git a/java/com/google/gerrit/server/schema/GrantRevertPermission.java b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
index 2f890d5..f3404bc 100644
--- a/java/com/google/gerrit/server/schema/GrantRevertPermission.java
+++ b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
@@ -18,9 +18,9 @@
 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.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -63,28 +64,41 @@
     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);
+      AtomicBoolean shouldExit = new AtomicBoolean(false);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            Permission permissionOnRefsHeads = heads.build().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);
-      }
+            if (permissionOnRefsHeads != null) {
+              if (permissionOnRefsHeads.getRule(registeredUsers) == null
+                  || permissionOnRefsHeads.getRules().size() > 1) {
+                // If admins already changed the permission, don't do anything.
+                shouldExit.set(true);
+                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.
+      if (shouldExit.get()) {
         return;
       }
-      // If the permission doesn't exist of refs/* for Registered Users, grant it.
-      grant(projectConfig, all, Permission.REVERT, registeredUsers);
+
+      projectConfig.upsertAccessSection(
+          AccessSection.ALL,
+          all -> {
+            Permission permissionOnRefsStar = all.build().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);
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
index c3c8f5e..d65268b 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
@@ -19,6 +19,7 @@
 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.index.group.GroupIndexCollection;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -30,7 +31,7 @@
  * <p>Implementations must have a single non-private constructor with no arguments (e.g. the default
  * constructor).
  */
-interface NoteDbSchemaVersion {
+public interface NoteDbSchemaVersion {
   @Singleton
   class Arguments {
     final GitRepositoryManager repoManager;
@@ -39,6 +40,7 @@
     final ProjectConfig.Factory projectConfigFactory;
     final SystemGroupBackend systemGroupBackend;
     final PersonIdent serverUser;
+    final GroupIndexCollection groupIndexCollection;
 
     @Inject
     Arguments(
@@ -47,13 +49,15 @@
         AllUsersName allUsers,
         ProjectConfig.Factory projectConfigFactory,
         SystemGroupBackend systemGroupBackend,
-        @GerritPersonIdent PersonIdent serverUser) {
+        @GerritPersonIdent PersonIdent serverUser,
+        GroupIndexCollection groupIndexCollection) {
       this.repoManager = repoManager;
       this.allProjects = allProjects;
       this.allUsers = allUsers;
       this.projectConfigFactory = projectConfigFactory;
       this.systemGroupBackend = systemGroupBackend;
       this.serverUser = serverUser;
+      this.groupIndexCollection = groupIndexCollection;
     }
   }
 
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
index 97c9f3a..209ff89 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
@@ -28,7 +28,12 @@
 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, Schema_182.class, Schema_183.class)
+      Stream.of(
+              Schema_180.class,
+              Schema_181.class,
+              Schema_182.class,
+              Schema_183.class,
+              Schema_184.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/ProjectConfigSchemaUpdate.java b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
index 5e7dbf0..868e7ea 100644
--- a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
+++ b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
@@ -19,7 +19,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -98,7 +98,7 @@
                     r -> {
                       PermissionRule rule = PermissionRule.fromString(r, false);
                       if (rule.getForce()) {
-                        rule.setForce(false);
+                        rule = rule.toBuilder().setForce(false).build();
                         updated = true;
                       }
                       return rule.asString(false);
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 78fa5bd..afa9d1a 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -16,8 +16,8 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.MetricMaker;
@@ -91,10 +91,13 @@
   @Override
   public void create() throws IOException, ConfigInvalidException {
     GroupReference admins = createGroupReference("Administrators");
-    GroupReference batchUsers = createGroupReference("Non-Interactive Users");
+    GroupReference serviceUsers = createGroupReference("Service Users");
 
     AllProjectsInput allProjectsInput =
-        AllProjectsInput.builder().administratorsGroup(admins).batchUsersGroup(batchUsers).build();
+        AllProjectsInput.builder()
+            .administratorsGroup(admins)
+            .serviceUsersGroup(serviceUsers)
+            .build();
     allProjectsCreator.create(allProjectsInput);
     // We have to create the All-Users repository before we can use it to store the groups in it.
     allUsersCreator.setAdministrators(admins).create();
@@ -111,7 +114,7 @@
             metricMaker);
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
       createAdminsGroup(seqs, allUsersRepo, admins);
-      createBatchUsersGroup(seqs, allUsersRepo, batchUsers, admins.getUUID());
+      createBatchUsersGroup(seqs, allUsersRepo, serviceUsers, admins.getUUID());
     }
   }
 
@@ -212,7 +215,7 @@
 
   private GroupReference createGroupReference(String name) {
     AccountGroup.UUID groupUuid = GroupUuid.make(name, serverUser);
-    return new GroupReference(groupUuid, name);
+    return GroupReference.create(groupUuid, name);
   }
 
   private InternalGroupCreation getGroupCreation(Sequences seqs, GroupReference groupReference) {
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
new file mode 100644
index 0000000..d0ca3d0
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.Nullable;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.AuditLogFormatter;
+import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupNameNotes;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Schema 184 for Gerrit metadata.
+ *
+ * <p>Upgrading to this schema version will rename the {@code Non-Interactive Users} group to {@code
+ * Service Users}.
+ */
+public class Schema_184 implements NoteDbSchemaVersion {
+  @Override
+  public void upgrade(Arguments args, UpdateUI ui) throws Exception {
+    try (Repository allUsersRepo = args.repoManager.openRepository(args.allUsers)) {
+      AccountGroup.NameKey newName = AccountGroup.nameKey("Service Users");
+      Optional<GroupReference> nonInteractiveUsers =
+          GroupNameNotes.loadAllGroups(allUsersRepo).stream()
+              .filter(g -> g.getName().equals("Non-Interactive Users"))
+              .findAny();
+      if (!nonInteractiveUsers.isPresent()) {
+        return;
+      }
+
+      GroupNameNotes newNameNotes =
+          GroupNameNotes.forRename(
+              args.allUsers,
+              allUsersRepo,
+              nonInteractiveUsers.get().getUUID(),
+              AccountGroup.nameKey(nonInteractiveUsers.get().getName()),
+              newName);
+      GroupConfig groupConfig =
+          GroupConfig.loadForGroup(
+              args.allUsers, allUsersRepo, nonInteractiveUsers.get().getUUID());
+      groupConfig.setGroupUpdate(
+          InternalGroupUpdate.builder().setName(newName).build(),
+          AuditLogFormatter.createPartiallyWorkingFallBack());
+      commit(args.allUsers, args.serverUser, allUsersRepo, groupConfig, newNameNotes);
+      index(
+          args.groupIndexCollection,
+          groupConfig
+              .getLoadedGroup()
+              .orElseThrow(
+                  () -> new IllegalStateException("Created group wasn't automatically loaded")));
+    }
+  }
+
+  private void commit(
+      AllUsersName allUsersName,
+      PersonIdent serverUser,
+      Repository allUsersRepo,
+      GroupConfig groupConfig,
+      GroupNameNotes groupNameNotes)
+      throws IOException {
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    try (MetaDataUpdate metaDataUpdate =
+        createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
+      groupConfig.commit(metaDataUpdate);
+    }
+    // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
+    try (MetaDataUpdate metaDataUpdate =
+        createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
+      groupNameNotes.commit(metaDataUpdate);
+    }
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+  }
+
+  private MetaDataUpdate createMetaDataUpdate(
+      AllUsersName allUsersName,
+      PersonIdent serverUser,
+      Repository allUsersRepo,
+      @Nullable BatchRefUpdate batchRefUpdate) {
+    MetaDataUpdate metaDataUpdate =
+        new MetaDataUpdate(
+            GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo, batchRefUpdate);
+    metaDataUpdate.getCommitBuilder().setAuthor(serverUser);
+    metaDataUpdate.getCommitBuilder().setCommitter(serverUser);
+    return metaDataUpdate;
+  }
+
+  private void index(GroupIndexCollection indexCollection, InternalGroup group) {
+    for (GroupIndex groupIndex : indexCollection.getWriteIndexes()) {
+      groupIndex.replace(group);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 8b159bc..5485192 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -48,8 +48,8 @@
       ImmutableList.of(
           "[capability]",
           "  administrateServer = group Administrators",
-          "  priority = batch group Non-Interactive Users",
-          "  streamEvents = group Non-Interactive Users");
+          "  priority = batch group Service Users",
+          "  streamEvents = group Service Users");
   private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_ACCESS_SECTION =
       ImmutableList.of(
           "[access \"refs/*\"]",
diff --git a/java/com/google/gerrit/server/ssh/SshAddressesModule.java b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
index 0a6bcac..1159e06 100644
--- a/java/com/google/gerrit/server/ssh/SshAddressesModule.java
+++ b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
@@ -40,7 +40,7 @@
   @Provides
   @Singleton
   @SshListenAddresses
-  public List<SocketAddress> getListenAddresses(@GerritServerConfig Config cfg) {
+  public List<SocketAddress> provideListenAddresses(@GerritServerConfig Config cfg) {
     List<SocketAddress> listen = Lists.newArrayListWithExpectedSize(2);
     String[] want = cfg.getStringList("sshd", null, "listenaddress");
     if (want == null || want.length == 0) {
@@ -71,7 +71,7 @@
   @Provides
   @Singleton
   @SshAdvertisedAddresses
-  List<String> getAdvertisedAddresses(
+  List<String> provideAdvertisedAddresses(
       @GerritServerConfig Config cfg, @SshListenAddresses List<SocketAddress> listen) {
     String[] want = cfg.getStringList("sshd", null, "advertisedaddress");
     if (want.length > 0) {
diff --git a/java/com/google/gerrit/server/submit/BranchTips.java b/java/com/google/gerrit/server/submit/BranchTips.java
new file mode 100644
index 0000000..d42517c
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/BranchTips.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.entities.BranchNameKey;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Ref;
+
+/**
+ * Current branch tips, taking into account commits created during the submit process as well as
+ * submodule updates produced by this class.
+ */
+class BranchTips {
+
+  private final Map<BranchNameKey, CodeReviewCommit> branchTips = new HashMap<>();
+
+  /**
+   * Returns current tip of the branch, taking into account commits created during the submit
+   * process or submodule updates.
+   *
+   * @param branch branch
+   * @param repo repository to look for the branch if not cached
+   * @return the current tip. Empty if the branch doesn't exist in the repository
+   * @throws IOException Cannot access the underlying storage
+   */
+  Optional<CodeReviewCommit> getTip(BranchNameKey branch, OpenRepo repo) throws IOException {
+    CodeReviewCommit currentCommit;
+    if (branchTips.containsKey(branch)) {
+      currentCommit = branchTips.get(branch);
+    } else {
+      Ref r = repo.repo.exactRef(branch.branch());
+      if (r == null) {
+        return Optional.empty();
+      }
+      currentCommit = repo.rw.parseCommit(r.getObjectId());
+      branchTips.put(branch, currentCommit);
+    }
+
+    return Optional.of(currentCommit);
+  }
+
+  void put(BranchNameKey branch, CodeReviewCommit c) {
+    branchTips.put(branch, c);
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index b66006a..a09ba63 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -193,7 +193,7 @@
       // was configured.
       MergeTip mergeTip = args.mergeTip;
       if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
-          && !args.submoduleOp.hasSubscription(args.destBranch)) {
+          && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
         PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
diff --git a/java/com/google/gerrit/server/submit/CircularPathFinder.java b/java/com/google/gerrit/server/submit/CircularPathFinder.java
new file mode 100644
index 0000000..d1920da
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/CircularPathFinder.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.submit;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+
+class CircularPathFinder {
+  private CircularPathFinder() {}
+
+  /**
+   * Prints a circular path according to the nodes in {@code p} and the start node {@code target}.
+   */
+  public static <T> String printCircularPath(Collection<T> p, T target) {
+    StringBuilder sb = new StringBuilder();
+    sb.append(target);
+    ArrayList<T> reverseP = new ArrayList<>(p);
+    Collections.reverse(reverseP);
+    for (T t : reverseP) {
+      sb.append("->");
+      sb.append(t);
+      if (t.equals(target)) {
+        break;
+      }
+    }
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java b/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java
new file mode 100644
index 0000000..3f3b544
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.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.submit;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Wrap a {@link SubscriptionGraph.Factory} to honor the gerrit configuration.
+ *
+ * <p>If superproject subscriptions are disabled in the conf, return an empty graph.
+ */
+public class ConfiguredSubscriptionGraphFactory implements SubscriptionGraph.Factory {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final SubscriptionGraph.Factory subscriptionGraphFactory;
+  private final Config cfg;
+
+  @Inject
+  ConfiguredSubscriptionGraphFactory(
+      @VanillaSubscriptionGraph SubscriptionGraph.Factory subscriptionGraphFactory,
+      @GerritServerConfig Config cfg) {
+    this.subscriptionGraphFactory = subscriptionGraphFactory;
+    this.cfg = cfg;
+  }
+
+  @Override
+  public SubscriptionGraph compute(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
+      throws SubmoduleConflictException {
+    if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
+      return subscriptionGraphFactory.compute(updatedBranches, orm);
+    }
+    logger.atFine().log("Updating superprojects disabled");
+    return SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index c94d49e..4efa4c8 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
@@ -38,20 +40,23 @@
   interface Factory {
     EmailMerge create(
         Project.NameKey project,
-        Change.Id changeId,
+        Change change,
         Account.Id submitter,
-        NotifyResolver.Result notify);
+        NotifyResolver.Result notify,
+        RepoView repoView);
   }
 
   private final ExecutorService sendEmailsExecutor;
   private final MergedSender.Factory mergedSenderFactory;
   private final ThreadLocalRequestContext requestContext;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final Project.NameKey project;
-  private final Change.Id changeId;
+  private final Change change;
   private final Account.Id submitter;
   private final NotifyResolver.Result notify;
+  private final RepoView repoView;
 
   @Inject
   EmailMerge(
@@ -59,18 +64,22 @@
       MergedSender.Factory mergedSenderFactory,
       ThreadLocalRequestContext requestContext,
       IdentifiedUser.GenericFactory identifiedUserFactory,
+      MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
+      @Assisted Change change,
       @Assisted @Nullable Account.Id submitter,
-      @Assisted NotifyResolver.Result notify) {
+      @Assisted NotifyResolver.Result notify,
+      @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.requestContext = requestContext;
     this.identifiedUserFactory = identifiedUserFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.project = project;
-    this.changeId = changeId;
+    this.change = change;
     this.submitter = submitter;
     this.notify = notify;
+    this.repoView = repoView;
   }
 
   void sendAsync() {
@@ -82,14 +91,16 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender cm = mergedSenderFactory.create(project, changeId);
+      MergedSender emailSender = mergedSenderFactory.create(project, change.getId());
       if (submitter != null) {
-        cm.setFrom(submitter);
+        emailSender.setFrom(submitter);
       }
-      cm.setNotify(notify);
-      cm.send();
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
+      emailSender.send();
     } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email merged notification for %s", changeId);
+      logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
     } finally {
       requestContext.setContext(old);
     }
diff --git a/java/com/google/gerrit/server/submit/GitlinkOp.java b/java/com/google/gerrit/server/submit/GitlinkOp.java
new file mode 100644
index 0000000..70a52b6
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/GitlinkOp.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.entities.BranchNameKey;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RepoOnlyOp;
+import java.util.Collection;
+import java.util.Optional;
+
+/** Only used for branches without code review changes */
+public class GitlinkOp implements RepoOnlyOp {
+
+  static class Factory {
+    private SubmoduleCommits submoduleCommits;
+    private SubscriptionGraph subscriptionGraph;
+
+    Factory(SubmoduleCommits submoduleCommits, SubscriptionGraph subscriptionGraph) {
+      this.submoduleCommits = submoduleCommits;
+      this.subscriptionGraph = subscriptionGraph;
+    }
+
+    GitlinkOp create(BranchNameKey branch) {
+      return new GitlinkOp(branch, submoduleCommits, subscriptionGraph.getSubscriptions(branch));
+    }
+  }
+
+  private final BranchNameKey branch;
+  private final SubmoduleCommits commitHelper;
+  private final Collection<SubmoduleSubscription> branchTargets;
+
+  GitlinkOp(
+      BranchNameKey branch,
+      SubmoduleCommits commitHelper,
+      Collection<SubmoduleSubscription> branchTargets) {
+    this.branch = branch;
+    this.commitHelper = commitHelper;
+    this.branchTargets = branchTargets;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws Exception {
+    Optional<CodeReviewCommit> commit = commitHelper.composeGitlinksCommit(branch, branchTargets);
+    if (commit.isPresent()) {
+      CodeReviewCommit c = commit.get();
+      ctx.addRefUpdate(c.getParent(0), c, branch.branch());
+      commitHelper.addBranchTip(branch, c);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 5f33dff..5d81ae2 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -22,9 +22,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
diff --git a/java/com/google/gerrit/server/submit/MergeIfNecessary.java b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
index 82499b3..30f1661 100644
--- a/java/com/google/gerrit/server/submit/MergeIfNecessary.java
+++ b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
@@ -30,7 +30,7 @@
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
 
     if (args.mergeTip.getInitialTip() == null
-        || !args.submoduleOp.hasSubscription(args.destBranch)) {
+        || !args.subscriptionGraph.hasSubscription(args.destBranch)) {
       CodeReviewCommit firstFastForward =
           args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
       if (firstFastForward != null && !firstFastForward.equals(args.mergeTip.getInitialTip())) {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index f96b0c5..fdf3664 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -24,6 +24,7 @@
 import com.github.rholder.retry.RetryListener;
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
@@ -32,15 +33,15 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 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.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -76,6 +77,9 @@
 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.SubmissionExecutor;
+import com.google.gerrit.server.update.SubmissionListener;
+import com.google.gerrit.server.update.SuperprojectUpdateOnSubmission;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -226,7 +230,9 @@
   private final MergeValidators.Factory mergeValidatorsFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final SubmitStrategyFactory submitStrategyFactory;
-  private final SubmoduleOp.Factory subOpFactory;
+  private final SubscriptionGraph.Factory subscriptionGraphFactory;
+  private final SubmoduleCommits.Factory submoduleCommitsFactory;
+  private final SubmissionListener superprojectUpdateSubmissionListener;
   private final Provider<MergeOpRepoManager> ormProvider;
   private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
@@ -256,7 +262,9 @@
       MergeValidators.Factory mergeValidatorsFactory,
       Provider<InternalChangeQuery> queryProvider,
       SubmitStrategyFactory submitStrategyFactory,
-      SubmoduleOp.Factory subOpFactory,
+      SubmoduleCommits.Factory submoduleCommitsFactory,
+      SubscriptionGraph.Factory subscriptionGraphFactory,
+      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
       Provider<MergeOpRepoManager> ormProvider,
       NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
@@ -269,7 +277,9 @@
     this.mergeValidatorsFactory = mergeValidatorsFactory;
     this.queryProvider = queryProvider;
     this.submitStrategyFactory = submitStrategyFactory;
-    this.subOpFactory = subOpFactory;
+    this.submoduleCommitsFactory = submoduleCommitsFactory;
+    this.subscriptionGraphFactory = subscriptionGraphFactory;
+    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
     this.ormProvider = ormProvider;
     this.notifyResolver = notifyResolver;
     this.retryHelper = retryHelper;
@@ -342,7 +352,7 @@
     }
     if (record.requirements != null) {
       record.requirements.stream()
-          .map(SubmitRequirement::fallbackText)
+          .map(MergeOp::describeSubmitRequirement)
           .forEach(blockingConditions::add);
     }
     return Joiner.on("; ").join(blockingConditions);
@@ -378,6 +388,10 @@
     return Joiner.on("; ").join(labelResults);
   }
 
+  private static String describeSubmitRequirement(SubmitRequirement submitRequirement) {
+    return String.format("Submit requirement not fulfilled: %s", submitRequirement.fallbackText());
+  }
+
   private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
       throws ResourceConflictException {
     checkArgument(
@@ -487,6 +501,8 @@
           topicMetrics.topicSubmissions.increment();
         }
 
+        SubmissionExecutor submissionExecutor =
+            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListener);
         RetryTracker retryTracker = new RetryTracker();
         retryHelper
             .changeUpdate(
@@ -507,7 +523,7 @@
                     logger.atFine().log("Bypassing submit rules");
                     bypassSubmitRules(cs, isRetry);
                   }
-                  integrateIntoHistory(cs);
+                  integrateIntoHistory(cs, submissionExecutor);
                   return null;
                 })
             .listener(retryTracker)
@@ -516,6 +532,7 @@
             // submit.
             .defaultTimeoutMultiplier(cs.projects().size())
             .call();
+        submissionExecutor.afterExecutions(orm);
 
         if (projects > 1) {
           topicMetrics.topicSubmissionsCompleted.increment();
@@ -581,7 +598,8 @@
     }
   }
 
-  private void integrateIntoHistory(ChangeSet cs) throws RestApiException, UpdateException {
+  private void integrateIntoHistory(ChangeSet cs, SubmissionExecutor submissionExecutor)
+      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<>();
@@ -605,19 +623,28 @@
     commitStatus.maybeFailVerbose();
 
     try {
-      SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
-      List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
-      this.allProjects = submoduleOp.getProjectsInOrder();
+      SubscriptionGraph subscriptionGraph = subscriptionGraphFactory.compute(branches, orm);
+      SubmoduleCommits submoduleCommits = submoduleCommitsFactory.create(orm);
+      UpdateOrderCalculator updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
+      List<SubmitStrategy> strategies =
+          getSubmitStrategies(
+              toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
+      this.allProjects = updateOrderCalculator.getProjectsInOrder();
+      List<BatchUpdate> batchUpdates = orm.batchUpdates(allProjects);
       try {
-        BatchUpdate.execute(
-            orm.batchUpdates(allProjects),
-            new SubmitStrategyListener(submitInput, strategies, commitStatus),
-            dryrun);
+        submissionExecutor.setAdditionalBatchUpdateListeners(
+            ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
+        submissionExecutor.execute(batchUpdates);
       } finally {
         // If the BatchUpdate fails it can be that merging some of the changes was actually
-        // successful. This is why we must to collect the updated changes also when an exception was
-        // thrown.
+        // successful. This is why we must to collect the updated changes also when an
+        // exception was thrown.
         strategies.forEach(s -> updatedChanges.putAll(s.getUpdatedChanges()));
+
+        // Do not leave executed BatchUpdates in the OpenRepos
+        if (!dryrun) {
+          orm.resetUpdates(ImmutableSet.copyOf(this.allProjects));
+        }
       }
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(e.getMessage());
@@ -658,12 +685,17 @@
   }
 
   private List<SubmitStrategy> getSubmitStrategies(
-      Map<BranchNameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
+      Map<BranchNameKey, BranchBatch> toSubmit,
+      UpdateOrderCalculator updateOrderCalculator,
+      SubmoduleCommits submoduleCommits,
+      SubscriptionGraph subscriptionGraph,
+      boolean dryrun)
       throws IntegrationConflictException, NoSuchProjectException, IOException {
     List<SubmitStrategy> strategies = new ArrayList<>();
-    Set<BranchNameKey> allBranches = submoduleOp.getBranchesInOrder();
+    Set<BranchNameKey> allBranches = updateOrderCalculator.getBranchesInOrder();
     Set<CodeReviewCommit> allCommits =
         toSubmit.values().stream().map(BranchBatch::commits).flatMap(Set::stream).collect(toSet());
+
     for (BranchNameKey branch : allBranches) {
       OpenRepo or = orm.getRepo(branch.project());
       if (toSubmit.containsKey(branch)) {
@@ -688,20 +720,14 @@
                 commitStatus,
                 submissionId,
                 submitInput,
-                submoduleOp,
+                submoduleCommits,
+                subscriptionGraph,
                 dryrun);
         strategies.add(strategy);
         strategy.addOps(or.getUpdate(), commitsToSubmit);
-        if (submitting.submitType().equals(SubmitType.FAST_FORWARD_ONLY)
-            && submoduleOp.hasSubscription(branch)) {
-          submoduleOp.addOp(or.getUpdate(), branch);
-        }
-      } else {
-        // no open change for this branch
-        // add submodule triggered op into BatchUpdate
-        submoduleOp.addOp(or.getUpdate(), branch);
       }
     }
+
     return strategies;
   }
 
@@ -736,7 +762,7 @@
     @Nullable
     abstract SubmitType submitType();
 
-    abstract Set<CodeReviewCommit> commits();
+    abstract ImmutableSet<CodeReviewCommit> commits();
   }
 
   private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted) {
@@ -842,7 +868,8 @@
 
       MergeValidators mergeValidators = mergeValidatorsFactory.create();
       try {
-        mergeValidators.validatePreMerge(or.repo, commit, or.project, destBranch, ps.id(), caller);
+        mergeValidators.validatePreMerge(
+            or.repo, or.rw, commit, or.project, destBranch, ps.id(), caller);
       } catch (MergeValidationException mve) {
         commitStatus.problem(changeId, mve.getMessage());
         continue;
@@ -851,7 +878,7 @@
       toSubmit.add(commit);
     }
     logger.atFine().log("Submitting on this run: %s", toSubmit);
-    return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
+    return new AutoValue_MergeOp_BranchBatch(submitType, ImmutableSet.copyOf(toSubmit));
   }
 
   private SetMultimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or, Collection<ChangeData> cds) {
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index b32c712..8981b07 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
@@ -119,6 +120,14 @@
       return update;
     }
 
+    // We want to reuse the open repo BUT not the BatchUpdate (because they are already executed)
+    public void resetExecutedUpdates() {
+      if (update != null && update.isExecuted()) {
+        update.close();
+        update = null;
+      }
+    }
+
     private void close() {
       if (update != null) {
         update.close();
@@ -206,6 +215,13 @@
     return updates;
   }
 
+  public void resetUpdates(ImmutableSet<Project.NameKey> projects)
+      throws NoSuchProjectException, IOException {
+    for (Project.NameKey project : projects) {
+      getRepo(project).resetExecutedUpdates();
+    }
+  }
+
   @Override
   public void close() {
     for (OpenRepo repo : openRepos.values()) {
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 33c3584..db48cce 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
 import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.BooleanProjectConfig;
@@ -62,20 +63,42 @@
     } catch (IOException | StorageException e) {
       throw new StorageException("Commit sorting failed", e);
     }
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
-    boolean first = true;
 
+    // We cannot rebase merge commits. This is why we integrate merge changes into the target branch
+    // the same way as if MERGE_IF_NECESSARY was the submit strategy. This means if needed we create
+    // a merge commit that integrates the merge change into the target branch.
+    // If we integrate a change series that consists out of a normal change and a merge change,
+    // where the merge change depends on the normal change, we must skip rebasing the normal change,
+    // because it already gets integrated by merging the merge change. If the rebasing of the normal
+    // change is not skipped, it would appear twice in the history after the submit is done (once
+    // through its rebased commit, and once through its original commit which is a parent of the
+    // merge change that was merged into the target branch. To skip the rebasing of the normal
+    // change, we call MergeUtil#reduceToMinimalMerge, as it excludes commits which will be
+    // implicitly integrated by merging the series. Then we use the MergeIfNecessaryOp to integrate
+    // the whole series.
+    // If on the other hand, we integrate a change series that consists out of a merge change and a
+    // normal change, where the normal change depends on the merge change, we can first integrate
+    // the merge change by a merge and then integrate the normal change by a rebase. In this case we
+    // do not want to call MergeUtil#reduceToMinimalMerge as we are not intending to integrate the
+    // whole series by a merge, but rather do the integration of the commits one by one.
+    boolean foundNonMerge = false;
     for (CodeReviewCommit c : sorted) {
       if (c.getParentCount() > 1) {
-        // Since there is a merge commit, sort and prune again using
-        // MERGE_IF_NECESSARY semantics to avoid creating duplicate
-        // commits.
-        //
+        if (!foundNonMerge) {
+          // found a merge change, but it doesn't depend on a normal change, this means we are not
+          // required to merge the whole series at once
+          continue;
+        }
+        // found a merge commit that depends on a normal change, this means we are required to merge
+        // the whole series at once
         sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
-        break;
+        return sorted.stream().map(n -> new MergeIfNecessaryOp(n)).collect(toList());
       }
+      foundNonMerge = true;
     }
 
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    boolean first = true;
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
       if (first && args.mergeTip.getInitialTip() == null) {
@@ -87,7 +110,7 @@
       } else if (n.getParentCount() == 1) {
         ops.add(new RebaseOneOp(n));
       } else {
-        ops.add(new RebaseMultipleParentsOp(n));
+        ops.add(new MergeIfNecessaryOp(n));
       }
       first = false;
     }
@@ -254,8 +277,8 @@
     }
   }
 
-  private class RebaseMultipleParentsOp extends SubmitStrategyOp {
-    private RebaseMultipleParentsOp(CodeReviewCommit toMerge) {
+  private class MergeIfNecessaryOp extends SubmitStrategyOp {
+    private MergeIfNecessaryOp(CodeReviewCommit toMerge) {
       super(RebaseSubmitStrategy.this.args, toMerge);
     }
 
@@ -275,7 +298,7 @@
       // merge commits.
       MergeTip mergeTip = args.mergeTip;
       if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
-          && !args.submoduleOp.hasSubscription(args.destBranch)) {
+          && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
         PersonIdent caller =
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 4010ad7..21ff2fc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -97,7 +97,8 @@
           Set<CodeReviewCommit> incoming,
           SubmissionId submissionId,
           SubmitInput submitInput,
-          SubmoduleOp submoduleOp,
+          SubmoduleCommits submoduleCommits,
+          SubscriptionGraph subscriptionGraph,
           boolean dryrun);
     }
 
@@ -129,7 +130,8 @@
     final SubmissionId submissionId;
     final SubmitType submitType;
     final SubmitInput submitInput;
-    final SubmoduleOp submoduleOp;
+    final SubscriptionGraph subscriptionGraph;
+    final SubmoduleCommits submoduleCommits;
 
     final ProjectState project;
     final MergeSorter mergeSorter;
@@ -168,7 +170,8 @@
         @Assisted SubmissionId submissionId,
         @Assisted SubmitType submitType,
         @Assisted SubmitInput submitInput,
-        @Assisted SubmoduleOp submoduleOp,
+        @Assisted SubscriptionGraph subscriptionGraph,
+        @Assisted SubmoduleCommits submoduleCommits,
         @Assisted boolean dryrun) {
       this.accountCache = accountCache;
       this.approvalsUtil = approvalsUtil;
@@ -197,7 +200,8 @@
       this.submissionId = submissionId;
       this.submitType = submitType;
       this.submitInput = submitInput;
-      this.submoduleOp = submoduleOp;
+      this.submoduleCommits = submoduleCommits;
+      this.subscriptionGraph = subscriptionGraph;
       this.dryrun = dryrun;
 
       this.project =
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 1cc78ff..2e66ae2 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -55,7 +55,8 @@
       CommitStatus commitStatus,
       SubmissionId submissionId,
       SubmitInput submitInput,
-      SubmoduleOp submoduleOp,
+      SubmoduleCommits submoduleCommits,
+      SubscriptionGraph subscriptionGraph,
       boolean dryrun) {
     SubmitStrategy.Arguments args =
         argsFactory.create(
@@ -70,7 +71,8 @@
             incoming,
             submissionId,
             submitInput,
-            submoduleOp,
+            submoduleCommits,
+            subscriptionGraph,
             dryrun);
     switch (submitType) {
       case CHERRY_PICK:
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index f35bf89..dfee2f8 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -22,8 +22,6 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -32,11 +30,11 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -135,7 +133,7 @@
         new ReceiveCommand(
             firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().branch());
     ctx.addRefUpdate(command);
-    args.submoduleOp.addBranchTip(getDest(), tipAfter);
+    args.submoduleCommits.addBranchTip(getDest(), tipAfter);
   }
 
   private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit) {
@@ -393,24 +391,13 @@
     }
   }
 
-  private String getByAccountName() {
-    requireNonNull(submitter, "getByAccountName called before submitter populated");
-    Optional<Account> account =
-        args.accountCache.get(submitter.accountId()).map(AccountState::account);
-    if (account.isPresent() && account.get().fullName() != null) {
-      return " by " + account.get().fullName();
-    }
-    return "";
-  }
-
   private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s) {
     requireNonNull(s, "CommitMergeStatus may not be null");
     String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
-      return message(ctx, commit.getPatchsetId(), txt + getByAccountName());
+      return message(ctx, commit.getPatchsetId(), txt);
     } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
-      return message(
-          ctx, commit.getPatchsetId(), txt + " as " + commit.name() + getByAccountName());
+      return message(ctx, commit.getPatchsetId(), txt + " as " + commit.name());
     } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
       return message(ctx, commit.getPatchsetId(), txt);
     } else if (s == CommitMergeStatus.ALREADY_MERGED) {
@@ -500,7 +487,12 @@
     // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
-          .create(ctx.getProject(), getId(), submitter.accountId(), ctx.getNotify(getId()))
+          .create(
+              ctx.getProject(),
+              toMerge.change(),
+              submitter.accountId(),
+              ctx.getNotify(getId()),
+              ctx.getRepoView())
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
@@ -543,13 +535,14 @@
    */
   protected CodeReviewCommit amendGitlink(CodeReviewCommit commit)
       throws IntegrationConflictException {
-    if (!args.submoduleOp.hasSubscription(args.destBranch)) {
+    if (!args.subscriptionGraph.hasSubscription(args.destBranch)) {
       return commit;
     }
 
     // Modify the commit with gitlink update
     try {
-      return args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
+      return args.submoduleCommits.amendGitlinksCommit(
+          args.destBranch, commit, args.subscriptionGraph.getSubscriptions(args.destBranch));
     } catch (IOException e) {
       throw new StorageException(
           String.format("cannot update gitlink for the commit at branch %s", args.destBranch), e);
diff --git a/java/com/google/gerrit/server/submit/SubmoduleCommits.java b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
new file mode 100644
index 0000000..1312a4b
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
@@ -0,0 +1,356 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+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.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import org.apache.commons.lang.StringUtils;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Create commit or amend existing one updating gitlinks. */
+class SubmoduleCommits {
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PersonIdent myIdent;
+  private final VerboseSuperprojectUpdate verboseSuperProject;
+  private final MergeOpRepoManager orm;
+  private final long maxCombinedCommitMessageSize;
+  private final long maxCommitMessages;
+  private final BranchTips branchTips = new BranchTips();
+
+  @Singleton
+  public static class Factory {
+    private final Provider<PersonIdent> serverIdent;
+    private final Config cfg;
+
+    @Inject
+    Factory(@GerritPersonIdent Provider<PersonIdent> serverIdent, @GerritServerConfig Config cfg) {
+      this.serverIdent = serverIdent;
+      this.cfg = cfg;
+    }
+
+    public SubmoduleCommits create(MergeOpRepoManager orm) {
+      return new SubmoduleCommits(orm, serverIdent.get(), cfg);
+    }
+  }
+
+  SubmoduleCommits(MergeOpRepoManager orm, PersonIdent myIdent, Config cfg) {
+    this.orm = orm;
+    this.myIdent = myIdent;
+    this.verboseSuperProject =
+        cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
+    this.maxCombinedCommitMessageSize =
+        cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
+    this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
+  }
+
+  /**
+   * Use the commit as tip of the branch
+   *
+   * <p>This keeps track of the tip of the branch as the submission progresses.
+   */
+  void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
+    branchTips.put(branch, tip);
+  }
+
+  /**
+   * Create a separate gitlink commit
+   *
+   * @param subscriber superproject (and branch)
+   * @param subscriptions subprojects the superproject is subscribed to
+   * @return a new commit on top of subscriber with gitlinks update to the tips of the subprojects;
+   *     empty if nothing has changed. Subproject tips are read from the cached branched tips
+   *     (defaulting to the mergeOpRepoManager).
+   */
+  Optional<CodeReviewCommit> composeGitlinksCommit(
+      BranchNameKey subscriber, Collection<SubmoduleSubscription> subscriptions)
+      throws IOException, SubmoduleConflictException {
+    OpenRepo or;
+    try {
+      or = orm.getRepo(subscriber.project());
+    } catch (NoSuchProjectException | IOException e) {
+      throw new StorageException("Cannot access superproject", e);
+    }
+
+    CodeReviewCommit currentCommit =
+        branchTips
+            .getTip(subscriber, or)
+            .orElseThrow(
+                () ->
+                    new SubmoduleConflictException(
+                        "The branch was probably deleted from the subscriber repository"));
+
+    StringBuilder msgbuf = new StringBuilder();
+    PersonIdent author = null;
+    DirCache dc = readTree(or.getCodeReviewRevWalk(), currentCommit);
+    DirCacheEditor ed = dc.editor();
+    int count = 0;
+
+    for (SubmoduleSubscription s : sortByPath(subscriptions)) {
+      if (count > 0) {
+        msgbuf.append("\n\n");
+      }
+      RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
+      count++;
+      if (newCommit != null) {
+        PersonIdent newCommitAuthor = newCommit.getAuthorIdent();
+        if (author == null) {
+          author = new PersonIdent(newCommitAuthor, myIdent.getWhen());
+        } else if (!author.getName().equals(newCommitAuthor.getName())
+            || !author.getEmailAddress().equals(newCommitAuthor.getEmailAddress())) {
+          author = myIdent;
+        }
+      }
+    }
+    ed.finish();
+    ObjectId newTreeId = dc.writeTree(or.ins);
+
+    // Gitlinks are already in the branch, return null
+    if (newTreeId.equals(currentCommit.getTree())) {
+      return Optional.empty();
+    }
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(newTreeId);
+    commit.setParentId(currentCommit);
+    StringBuilder commitMsg = new StringBuilder("Update git submodules\n\n");
+    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+      commitMsg.append(msgbuf);
+    }
+    commit.setMessage(commitMsg.toString());
+    commit.setAuthor(author);
+    commit.setCommitter(myIdent);
+    ObjectId id = or.ins.insert(commit);
+    return Optional.of(or.getCodeReviewRevWalk().parseCommit(id));
+  }
+
+  /** Amend an existing commit with gitlink updates */
+  CodeReviewCommit amendGitlinksCommit(
+      BranchNameKey subscriber,
+      CodeReviewCommit currentCommit,
+      Collection<SubmoduleSubscription> subscriptions)
+      throws IOException, SubmoduleConflictException {
+    OpenRepo or;
+    try {
+      or = orm.getRepo(subscriber.project());
+    } catch (NoSuchProjectException | IOException e) {
+      throw new StorageException("Cannot access superproject", e);
+    }
+
+    StringBuilder msgbuf = new StringBuilder();
+    DirCache dc = readTree(or.rw, currentCommit);
+    DirCacheEditor ed = dc.editor();
+    for (SubmoduleSubscription s : sortByPath(subscriptions)) {
+      updateSubmodule(dc, ed, msgbuf, s);
+    }
+    ed.finish();
+    ObjectId newTreeId = dc.writeTree(or.ins);
+
+    // Gitlinks are already updated, just return the commit
+    if (newTreeId.equals(currentCommit.getTree())) {
+      return currentCommit;
+    }
+    or.rw.parseBody(currentCommit);
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(newTreeId);
+    commit.setParentIds(currentCommit.getParents());
+    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+      // TODO(czhen): handle cherrypick footer
+      commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
+    } else {
+      commit.setMessage(currentCommit.getFullMessage());
+    }
+    commit.setAuthor(currentCommit.getAuthorIdent());
+    commit.setCommitter(myIdent);
+    ObjectId id = or.ins.insert(commit);
+    CodeReviewCommit newCommit = or.getCodeReviewRevWalk().parseCommit(id);
+    newCommit.copyFrom(currentCommit);
+    return newCommit;
+  }
+
+  private RevCommit updateSubmodule(
+      DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
+      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 StorageException("Cannot access submodule", e);
+    }
+
+    DirCacheEntry dce = dc.getEntry(s.getPath());
+    RevCommit oldCommit = null;
+    if (dce != null) {
+      if (!dce.getFileMode().equals(FileMode.GITLINK)) {
+        String errMsg =
+            "Requested to update gitlink "
+                + s.getPath()
+                + " in "
+                + s.getSubmodule().project().get()
+                + " but entry "
+                + "doesn't have gitlink file mode.";
+        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.
+      //
+      // Even if we don't strictly speaking need that commit message, parsing the commit is a sanity
+      // check that the old gitlink is a commit that actually exists. If not, then there is an
+      // inconsistency between the superproject and subproject state, and we don't want to risk
+      // making things worse by updating the gitlink to something else.
+      try {
+        oldCommit = subOr.getCodeReviewRevWalk().parseCommit(dce.getObjectId());
+      } catch (IOException e) {
+        // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
+        // proceed, it will just skip this gitlink update.
+        logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
+        return null;
+      }
+    }
+
+    Optional<CodeReviewCommit> maybeNewCommit = branchTips.getTip(s.getSubmodule(), subOr);
+    if (!maybeNewCommit.isPresent()) {
+      // For whatever reason, this submodule was not updated as part of this submit batch, but the
+      // superproject is still subscribed to this branch. Re-read the ref to see if anything has
+      // changed since the last time the gitlink was updated, and roll that update into the same
+      // commit as all other submodule updates.
+      ed.add(new DeletePath(s.getPath()));
+      return null;
+    }
+
+    CodeReviewCommit newCommit = maybeNewCommit.get();
+    if (Objects.equals(newCommit, oldCommit)) {
+      // gitlink have already been updated for this submodule
+      return null;
+    }
+    ed.add(
+        new PathEdit(s.getPath()) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.GITLINK);
+            ent.setObjectId(newCommit.getId());
+          }
+        });
+
+    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+      createSubmoduleCommitMsg(msgbuf, s, subOr, newCommit, oldCommit);
+    }
+    subOr.getCodeReviewRevWalk().parseBody(newCommit);
+    return newCommit;
+  }
+
+  private void createSubmoduleCommitMsg(
+      StringBuilder msgbuf,
+      SubmoduleSubscription s,
+      OpenRepo subOr,
+      RevCommit newCommit,
+      RevCommit oldCommit) {
+    msgbuf.append("* Update ");
+    msgbuf.append(s.getPath());
+    msgbuf.append(" from branch '");
+    msgbuf.append(s.getSubmodule().shortName());
+    msgbuf.append("'");
+    msgbuf.append("\n  to ");
+    msgbuf.append(newCommit.getName());
+
+    // newly created submodule gitlink, do not append whole history
+    if (oldCommit == null) {
+      return;
+    }
+
+    try {
+      subOr.rw.resetRetain(subOr.canMergeFlag);
+      subOr.rw.markStart(newCommit);
+      subOr.rw.markUninteresting(oldCommit);
+      int numMessages = 0;
+      for (Iterator<RevCommit> iter = subOr.rw.iterator(); iter.hasNext(); ) {
+        RevCommit c = iter.next();
+        subOr.rw.parseBody(c);
+
+        String message =
+            verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY
+                ? c.getShortMessage()
+                : StringUtils.replace(c.getFullMessage(), "\n", "\n    ");
+
+        String bullet = "\n  - ";
+        String ellipsis = "\n\n[...]";
+        int newSize = msgbuf.length() + bullet.length() + message.length();
+        if (++numMessages > maxCommitMessages
+            || newSize > maxCombinedCommitMessageSize
+            || (iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize)) {
+          msgbuf.append(ellipsis);
+          break;
+        }
+        msgbuf.append(bullet);
+        msgbuf.append(message);
+      }
+    } catch (IOException e) {
+      throw new StorageException(
+          "Could not perform a revwalk to create superproject commit message", e);
+    }
+  }
+
+  private static DirCache readTree(RevWalk rw, ObjectId base) throws IOException {
+    final DirCache dc = DirCache.newInCore();
+    final DirCacheBuilder b = dc.builder();
+    b.addTree(
+        new byte[0], // no prefix path
+        DirCacheEntry.STAGE_0, // standard stage
+        rw.getObjectReader(),
+        rw.parseTree(base));
+    b.finish();
+    return dc;
+  }
+
+  private static List<SubmoduleSubscription> sortByPath(
+      Collection<SubmoduleSubscription> subscriptions) {
+    return subscriptions.stream()
+        .sorted(comparing(SubmoduleSubscription::getPath))
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index b48076194..69d76e2 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,729 +14,118 @@
 
 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;
-
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.entities.BranchNameKey;
 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;
-import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateListener;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Deque;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
 import java.util.LinkedHashSet;
-import java.util.List;
 import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import org.apache.commons.lang.StringUtils;
-import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEditor;
-import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
-import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class SubmoduleOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  /** Only used for branches without code review changes */
-  public class GitlinkOp implements RepoOnlyOp {
-    private final BranchNameKey branch;
-
-    GitlinkOp(BranchNameKey branch) {
-      this.branch = branch;
-    }
-
-    @Override
-    public void updateRepo(RepoContext ctx) throws Exception {
-      CodeReviewCommit c = composeGitlinksCommit(branch);
-      if (c != null) {
-        ctx.addRefUpdate(c.getParent(0), c, branch.branch());
-        addBranchTip(branch, c);
-      }
-    }
-  }
 
   @Singleton
   public static class Factory {
-    private final GitModules.Factory gitmodulesFactory;
-    private final Provider<PersonIdent> serverIdent;
-    private final Config cfg;
-    private final ProjectCache projectCache;
+    private final SubscriptionGraph.Factory subscriptionGraphFactory;
+    private final SubmoduleCommits.Factory submoduleCommitsFactory;
 
     @Inject
     Factory(
-        GitModules.Factory gitmodulesFactory,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        @GerritServerConfig Config cfg,
-        ProjectCache projectCache) {
-      this.gitmodulesFactory = gitmodulesFactory;
-      this.serverIdent = serverIdent;
-      this.cfg = cfg;
-      this.projectCache = projectCache;
+        SubscriptionGraph.Factory subscriptionGraphFactory,
+        SubmoduleCommits.Factory submoduleCommitsFactory) {
+      this.subscriptionGraphFactory = subscriptionGraphFactory;
+      this.submoduleCommitsFactory = submoduleCommitsFactory;
     }
 
-    public SubmoduleOp create(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
+    public SubmoduleOp create(
+        Map<BranchNameKey, ReceiveCommand> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleConflictException {
       return new SubmoduleOp(
-          gitmodulesFactory, serverIdent.get(), cfg, projectCache, updatedBranches, orm);
+          updatedBranches,
+          orm,
+          subscriptionGraphFactory.compute(updatedBranches.keySet(), orm),
+          submoduleCommitsFactory.create(orm));
     }
   }
 
-  private final GitModules.Factory gitmodulesFactory;
-  private final PersonIdent myIdent;
-  private final ProjectCache projectCache;
-  private final VerboseSuperprojectUpdate verboseSuperProject;
-  private final boolean enableSuperProjectSubscriptions;
-  private final long maxCombinedCommitMessageSize;
-  private final long maxCommitMessages;
+  private final Map<BranchNameKey, ReceiveCommand> updatedBranches;
   private final MergeOpRepoManager orm;
-  private final Map<BranchNameKey, GitModules> branchGitModules;
-
-  /** Branches updated as part of the enclosing submit or push batch. */
-  private final ImmutableSet<BranchNameKey> updatedBranches;
-
-  /**
-   * Current branch tips, taking into account commits created during the submit process as well as
-   * submodule updates produced by this class.
-   */
-  private final Map<BranchNameKey, CodeReviewCommit> branchTips;
-
-  /**
-   * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
-   * which are subscribed to by some superproject.
-   */
-  private final Set<BranchNameKey> affectedBranches;
-
-  /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
-  private final ImmutableSet<BranchNameKey> sortedBranches;
-
-  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
-  private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
-
-  /**
-   * Multimap of superproject name to all branch names within that superproject which have submodule
-   * subscriptions.
-   */
-  private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+  private final SubscriptionGraph subscriptionGraph;
+  private final SubmoduleCommits submoduleCommits;
+  private final UpdateOrderCalculator updateOrderCalculator;
 
   private SubmoduleOp(
-      GitModules.Factory gitmodulesFactory,
-      PersonIdent myIdent,
-      Config cfg,
-      ProjectCache projectCache,
-      Set<BranchNameKey> updatedBranches,
-      MergeOpRepoManager orm)
-      throws SubmoduleConflictException {
-    this.gitmodulesFactory = gitmodulesFactory;
-    this.myIdent = myIdent;
-    this.projectCache = projectCache;
-    this.verboseSuperProject =
-        cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
-    this.enableSuperProjectSubscriptions =
-        cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true);
-    this.maxCombinedCommitMessageSize =
-        cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
-    this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
+      Map<BranchNameKey, ReceiveCommand> updatedBranches,
+      MergeOpRepoManager orm,
+      SubscriptionGraph subscriptionGraph,
+      SubmoduleCommits submoduleCommits) {
+    this.updatedBranches = updatedBranches;
     this.orm = orm;
-    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
-    this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.affectedBranches = new HashSet<>();
-    this.branchTips = new HashMap<>();
-    this.branchGitModules = new HashMap<>();
-    this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.sortedBranches = calculateSubscriptionMaps();
+    this.subscriptionGraph = subscriptionGraph;
+    this.submoduleCommits = submoduleCommits;
+    this.updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
   }
 
-  /**
-   * Calculate the internal maps used by the operation.
-   *
-   * <p>In addition to the return value, the following fields are populated as a side effect:
-   *
-   * <ul>
-   *   <li>{@link #affectedBranches}
-   *   <li>{@link #targets}
-   *   <li>{@link #branchesByProject}
-   * </ul>
-   *
-   * @return the ordered set to be stored in {@link #sortedBranches}.
-   */
-  // 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.
-  //
-  // A cleaner architecture for this process might be:
-  //   1. Separate out the code to parse submodule subscriptions and build up an in-memory data
-  //      structure representing the subscription graph, using a separate class with a properly-
-  //      documented interface.
-  //   2. Walk the graph to produce a work plan. This would be a list of items indicating: create a
-  //      commit in project X reading branch tips for submodules S1..Sn and updating gitlinks in X.
-  //   3. Execute the work plan, i.e. convert the items into BatchUpdate.Ops and add them to the
-  //      relevant updates.
-  //
-  // In addition to improving readability, this approach has the advantage of making (1) and (2)
-  // testable using small tests.
-  private ImmutableSet<BranchNameKey> calculateSubscriptionMaps()
-      throws SubmoduleConflictException {
-    if (!enableSuperProjectSubscriptions) {
-      logger.atFine().log("Updating superprojects disabled");
-      return null;
-    }
-
-    logger.atFine().log("Calculating superprojects - submodules map");
-    LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
-    for (BranchNameKey updatedBranch : updatedBranches) {
-      if (allVisited.contains(updatedBranch)) {
-        continue;
-      }
-
-      searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
-    }
-
-    // Since the searchForSuperprojects will add all branches (related or
-    // unrelated) and ensure the superproject's branches get added first before
-    // a submodule branch. Need remove all unrelated branches and reverse
-    // the order.
-    allVisited.retainAll(affectedBranches);
-    reverse(allVisited);
-    return ImmutableSet.copyOf(allVisited);
-  }
-
-  private void searchForSuperprojects(
-      BranchNameKey current,
-      LinkedHashSet<BranchNameKey> currentVisited,
-      LinkedHashSet<BranchNameKey> allVisited)
-      throws SubmoduleConflictException {
-    logger.atFine().log("Now processing %s", current);
-
-    if (currentVisited.contains(current)) {
-      throw new SubmoduleConflictException(
-          "Branch level circular subscriptions detected:  "
-              + printCircularPath(currentVisited, current));
-    }
-
-    if (allVisited.contains(current)) {
-      return;
-    }
-
-    currentVisited.add(current);
-    try {
-      Collection<SubmoduleSubscription> subscriptions =
-          superProjectSubscriptionsForSubmoduleBranch(current);
-      for (SubmoduleSubscription sub : subscriptions) {
-        BranchNameKey superBranch = sub.getSuperProject();
-        searchForSuperprojects(superBranch, currentVisited, allVisited);
-        targets.put(superBranch, sub);
-        branchesByProject.put(superBranch.project(), superBranch);
-        affectedBranches.add(superBranch);
-        affectedBranches.add(sub.getSubmodule());
-      }
-    } catch (IOException e) {
-      throw new StorageException("Cannot find superprojects for " + current, e);
-    }
-    currentVisited.remove(current);
-    allVisited.add(current);
-  }
-
-  private static <T> void reverse(LinkedHashSet<T> set) {
-    if (set == null) {
-      return;
-    }
-
-    Deque<T> q = new ArrayDeque<>(set);
-    set.clear();
-
-    while (!q.isEmpty()) {
-      set.add(q.removeLast());
-    }
-  }
-
-  private <T> String printCircularPath(LinkedHashSet<T> p, T target) {
-    StringBuilder sb = new StringBuilder();
-    sb.append(target);
-    ArrayList<T> reverseP = new ArrayList<>(p);
-    Collections.reverse(reverseP);
-    for (T t : reverseP) {
-      sb.append("->");
-      sb.append(t);
-      if (t.equals(target)) {
-        break;
-      }
-    }
-    return sb.toString();
-  }
-
-  private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
-      throws IOException {
-    Collection<BranchNameKey> ret = new HashSet<>();
-    logger.atFine().log("Inspecting SubscribeSection %s", s);
-    for (RefSpec r : s.getMatchingRefSpecs()) {
-      logger.atFine().log("Inspecting [matching] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      if (r.isWildcard()) {
-        // refs/heads/*[:refs/somewhere/*]
-        ret.add(
-            BranchNameKey.create(
-                s.getProject(), r.expandFromSource(src.branch()).getDestination()));
-      } else {
-        // e.g. refs/heads/master[:refs/heads/stable]
-        String dest = r.getDestination();
-        if (dest == null) {
-          dest = r.getSource();
-        }
-        ret.add(BranchNameKey.create(s.getProject(), dest));
-      }
-    }
-
-    for (RefSpec r : s.getMultiMatchRefSpecs()) {
-      logger.atFine().log("Inspecting [all] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      OpenRepo or;
-      try {
-        or = orm.getRepo(s.getProject());
-      } catch (NoSuchProjectException e) {
-        // A project listed a non existent project to be allowed
-        // to subscribe to it. Allow this for now, i.e. no exception is
-        // thrown.
-        continue;
-      }
-
-      for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
-        if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
-          continue;
-        }
-        BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
-        if (!ret.contains(b)) {
-          ret.add(b);
-        }
-      }
-    }
-    logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
-    return ret;
-  }
-
-  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
-  public Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
-      BranchNameKey srcBranch) throws IOException {
-    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)
-            .orElseThrow(illegalState(srcProject))
-            .getSubscribeSections(srcBranch)) {
-      logger.atFine().log("Checking subscribe section %s", s);
-      Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
-      for (BranchNameKey targetBranch : branches) {
-        Project.NameKey targetProject = targetBranch.project();
-        try {
-          OpenRepo or = orm.getRepo(targetProject);
-          ObjectId id = or.repo.resolve(targetBranch.branch());
-          if (id == null) {
-            logger.atFine().log("The branch %s doesn't exist.", targetBranch);
-            continue;
-          }
-        } catch (NoSuchProjectException e) {
-          logger.atFine().log("The project %s doesn't exist", targetProject);
-          continue;
-        }
-
-        GitModules m = branchGitModules.get(targetBranch);
-        if (m == null) {
-          m = gitmodulesFactory.create(targetBranch, orm);
-          branchGitModules.put(targetBranch, m);
-        }
-        ret.addAll(m.subscribedTo(srcBranch));
-      }
-    }
-    logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
-    return ret;
-  }
-
-  public void updateSuperProjects() throws RestApiException {
-    ImmutableSet<Project.NameKey> projects = getProjectsInOrder();
+  public void updateSuperProjects(boolean dryrun) throws RestApiException {
+    ImmutableSet<Project.NameKey> projects = updateOrderCalculator.getProjectsInOrder();
     if (projects == null) {
       return;
     }
 
+    if (dryrun) {
+      // On dryrun, the refs hasn't been updated.
+      // force the new tips on submoduleCommits
+      forceRefTips(updatedBranches, submoduleCommits);
+    }
+
     LinkedHashSet<Project.NameKey> superProjects = new LinkedHashSet<>();
     try {
+      GitlinkOp.Factory gitlinkOpFactory =
+          new GitlinkOp.Factory(submoduleCommits, subscriptionGraph);
       for (Project.NameKey project : projects) {
         // only need superprojects
-        if (branchesByProject.containsKey(project)) {
+        if (subscriptionGraph.isAffectedSuperProject(project)) {
           superProjects.add(project);
           // get a new BatchUpdate for the super project
           OpenRepo or = orm.getRepo(project);
-          for (BranchNameKey branch : branchesByProject.get(project)) {
-            addOp(or.getUpdate(), branch);
+          for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
+            or.getUpdate().addRepoOnlyOp(gitlinkOpFactory.create(branch));
           }
         }
       }
-      BatchUpdate.execute(orm.batchUpdates(superProjects), BatchUpdateListener.NONE, false);
+      BatchUpdate.execute(orm.batchUpdates(superProjects), ImmutableList.of(), dryrun);
     } catch (UpdateException | IOException | NoSuchProjectException e) {
       throw new StorageException("Cannot update gitlinks", e);
     }
   }
 
-  /** Create a separate gitlink commit */
-  private CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber)
-      throws IOException, SubmoduleConflictException {
-    OpenRepo or;
-    try {
-      or = orm.getRepo(subscriber.project());
-    } catch (NoSuchProjectException | IOException e) {
-      throw new StorageException("Cannot access superproject", e);
-    }
-
-    CodeReviewCommit currentCommit;
-    if (branchTips.containsKey(subscriber)) {
-      currentCommit = branchTips.get(subscriber);
-    } else {
-      Ref r = or.repo.exactRef(subscriber.branch());
-      if (r == null) {
-        throw new SubmoduleConflictException(
-            "The branch was probably deleted from the subscriber repository");
-      }
-      currentCommit = or.rw.parseCommit(r.getObjectId());
-      addBranchTip(subscriber, currentCommit);
-    }
-
-    StringBuilder msgbuf = new StringBuilder();
-    PersonIdent author = null;
-    DirCache dc = readTree(or.rw, currentCommit);
-    DirCacheEditor ed = dc.editor();
-    int count = 0;
-
-    List<SubmoduleSubscription> subscriptions =
-        targets.get(subscriber).stream()
-            .sorted(comparing(SubmoduleSubscription::getPath))
-            .collect(toList());
-    for (SubmoduleSubscription s : subscriptions) {
-      if (count > 0) {
-        msgbuf.append("\n\n");
-      }
-      RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
-      count++;
-      if (newCommit != null) {
-        PersonIdent newCommitAuthor = newCommit.getAuthorIdent();
-        if (author == null) {
-          author = new PersonIdent(newCommitAuthor, myIdent.getWhen());
-        } else if (!author.getName().equals(newCommitAuthor.getName())
-            || !author.getEmailAddress().equals(newCommitAuthor.getEmailAddress())) {
-          author = myIdent;
-        }
-      }
-    }
-    ed.finish();
-    ObjectId newTreeId = dc.writeTree(or.ins);
-
-    // Gitlinks are already in the branch, return null
-    if (newTreeId.equals(currentCommit.getTree())) {
-      return null;
-    }
-    CommitBuilder commit = new CommitBuilder();
-    commit.setTreeId(newTreeId);
-    commit.setParentId(currentCommit);
-    StringBuilder commitMsg = new StringBuilder("Update git submodules\n\n");
-    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
-      commitMsg.append(msgbuf);
-    }
-    commit.setMessage(commitMsg.toString());
-    commit.setAuthor(author);
-    commit.setCommitter(myIdent);
-    ObjectId id = or.ins.insert(commit);
-    return or.rw.parseCommit(id);
-  }
-
-  /** Amend an existing commit with gitlink updates */
-  CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
-      throws IOException, SubmoduleConflictException {
-    OpenRepo or;
-    try {
-      or = orm.getRepo(subscriber.project());
-    } catch (NoSuchProjectException | IOException e) {
-      throw new StorageException("Cannot access superproject", e);
-    }
-
-    StringBuilder msgbuf = new StringBuilder();
-    DirCache dc = readTree(or.rw, currentCommit);
-    DirCacheEditor ed = dc.editor();
-    for (SubmoduleSubscription s : targets.get(subscriber)) {
-      updateSubmodule(dc, ed, msgbuf, s);
-    }
-    ed.finish();
-    ObjectId newTreeId = dc.writeTree(or.ins);
-
-    // Gitlinks are already updated, just return the commit
-    if (newTreeId.equals(currentCommit.getTree())) {
-      return currentCommit;
-    }
-    or.rw.parseBody(currentCommit);
-    CommitBuilder commit = new CommitBuilder();
-    commit.setTreeId(newTreeId);
-    commit.setParentIds(currentCommit.getParents());
-    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
-      // TODO(czhen): handle cherrypick footer
-      commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
-    } else {
-      commit.setMessage(currentCommit.getFullMessage());
-    }
-    commit.setAuthor(currentCommit.getAuthorIdent());
-    commit.setCommitter(myIdent);
-    ObjectId id = or.ins.insert(commit);
-    CodeReviewCommit newCommit = or.rw.parseCommit(id);
-    newCommit.copyFrom(currentCommit);
-    return newCommit;
-  }
-
-  private RevCommit updateSubmodule(
-      DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
-      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 StorageException("Cannot access submodule", e);
-    }
-
-    DirCacheEntry dce = dc.getEntry(s.getPath());
-    RevCommit oldCommit = null;
-    if (dce != null) {
-      if (!dce.getFileMode().equals(FileMode.GITLINK)) {
-        String errMsg =
-            "Requested to update gitlink "
-                + s.getPath()
-                + " in "
-                + s.getSubmodule().project().get()
-                + " but entry "
-                + "doesn't have gitlink file mode.";
-        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.
-      //
-      // Even if we don't strictly speaking need that commit message, parsing the commit is a sanity
-      // check that the old gitlink is a commit that actually exists. If not, then there is an
-      // inconsistency between the superproject and subproject state, and we don't want to risk
-      // making things worse by updating the gitlink to something else.
+  private void forceRefTips(
+      Map<BranchNameKey, ReceiveCommand> updatedBranches, SubmoduleCommits submoduleCommits) {
+    // This is dryrun, all commands succeeded (no need to filter success).
+    for (Map.Entry<BranchNameKey, ReceiveCommand> updateBranch : updatedBranches.entrySet()) {
       try {
-        oldCommit = subOr.rw.parseCommit(dce.getObjectId());
-      } catch (IOException e) {
-        // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
-        // proceed, it will just skip this gitlink update.
-        logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
-        return null;
-      }
-    }
-
-    final CodeReviewCommit newCommit;
-    if (branchTips.containsKey(s.getSubmodule())) {
-      // This submodule's branch was updated as part of this specific submit batch: update the
-      // gitlink to point to the new commit from the batch.
-      newCommit = branchTips.get(s.getSubmodule());
-    } else {
-      // For whatever reason, this submodule was not updated as part of this submit batch, but the
-      // superproject is still subscribed to this branch. Re-read the ref to see if anything has
-      // changed since the last time the gitlink was updated, and roll that update into the same
-      // commit as all other submodule updates.
-      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().branch());
-      if (ref == null) {
-        ed.add(new DeletePath(s.getPath()));
-        return null;
-      }
-      newCommit = subOr.rw.parseCommit(ref.getObjectId());
-      addBranchTip(s.getSubmodule(), newCommit);
-    }
-
-    if (Objects.equals(newCommit, oldCommit)) {
-      // gitlink have already been updated for this submodule
-      return null;
-    }
-    ed.add(
-        new PathEdit(s.getPath()) {
-          @Override
-          public void apply(DirCacheEntry ent) {
-            ent.setFileMode(FileMode.GITLINK);
-            ent.setObjectId(newCommit.getId());
-          }
-        });
-
-    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
-      createSubmoduleCommitMsg(msgbuf, s, subOr, newCommit, oldCommit);
-    }
-    subOr.rw.parseBody(newCommit);
-    return newCommit;
-  }
-
-  private void createSubmoduleCommitMsg(
-      StringBuilder msgbuf,
-      SubmoduleSubscription s,
-      OpenRepo subOr,
-      RevCommit newCommit,
-      RevCommit oldCommit) {
-    msgbuf.append("* Update ");
-    msgbuf.append(s.getPath());
-    msgbuf.append(" from branch '");
-    msgbuf.append(s.getSubmodule().shortName());
-    msgbuf.append("'");
-    msgbuf.append("\n  to ");
-    msgbuf.append(newCommit.getName());
-
-    // newly created submodule gitlink, do not append whole history
-    if (oldCommit == null) {
-      return;
-    }
-
-    try {
-      subOr.rw.resetRetain(subOr.canMergeFlag);
-      subOr.rw.markStart(newCommit);
-      subOr.rw.markUninteresting(oldCommit);
-      int numMessages = 0;
-      for (Iterator<RevCommit> iter = subOr.rw.iterator(); iter.hasNext(); ) {
-        RevCommit c = iter.next();
-        subOr.rw.parseBody(c);
-
-        String message =
-            verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY
-                ? c.getShortMessage()
-                : StringUtils.replace(c.getFullMessage(), "\n", "\n    ");
-
-        String bullet = "\n  - ";
-        String ellipsis = "\n\n[...]";
-        int newSize = msgbuf.length() + bullet.length() + message.length();
-        if (++numMessages > maxCommitMessages
-            || newSize > maxCombinedCommitMessageSize
-            || (iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize)) {
-          msgbuf.append(ellipsis);
-          break;
+        ReceiveCommand command = updateBranch.getValue();
+        if (command.getType() == ReceiveCommand.Type.DELETE) {
+          continue;
         }
-        msgbuf.append(bullet);
-        msgbuf.append(message);
-      }
-    } catch (IOException e) {
-      throw new StorageException(
-          "Could not perform a revwalk to create superproject commit message", e);
-    }
-  }
 
-  private static DirCache readTree(RevWalk rw, ObjectId base) throws IOException {
-    final DirCache dc = DirCache.newInCore();
-    final DirCacheBuilder b = dc.builder();
-    b.addTree(
-        new byte[0], // no prefix path
-        DirCacheEntry.STAGE_0, // standard stage
-        rw.getObjectReader(),
-        rw.parseTree(base));
-    b.finish();
-    return dc;
-  }
-
-  ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
-    LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
-    for (Project.NameKey project : branchesByProject.keySet()) {
-      addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
-    }
-
-    for (BranchNameKey branch : updatedBranches) {
-      projects.add(branch.project());
-    }
-    return ImmutableSet.copyOf(projects);
-  }
-
-  private void addAllSubmoduleProjects(
-      Project.NameKey project,
-      LinkedHashSet<Project.NameKey> current,
-      LinkedHashSet<Project.NameKey> projects)
-      throws SubmoduleConflictException {
-    if (current.contains(project)) {
-      throw new SubmoduleConflictException(
-          "Project level circular subscriptions detected:  " + printCircularPath(current, project));
-    }
-
-    if (projects.contains(project)) {
-      return;
-    }
-
-    current.add(project);
-    Set<Project.NameKey> subprojects = new HashSet<>();
-    for (BranchNameKey branch : branchesByProject.get(project)) {
-      Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
-      for (SubmoduleSubscription s : subscriptions) {
-        subprojects.add(s.getSubmodule().project());
+        BranchNameKey branchNameKey = updateBranch.getKey();
+        OpenRepo openRepo = orm.getRepo(branchNameKey.project());
+        CodeReviewCommit fakeTip = openRepo.rw.parseCommit(command.getNewId());
+        submoduleCommits.addBranchTip(branchNameKey, fakeTip);
+      } catch (NoSuchProjectException | IOException e) {
+        throw new StorageException("Cannot find branch tip target in dryrun", e);
       }
     }
-
-    for (Project.NameKey p : subprojects) {
-      addAllSubmoduleProjects(p, current, projects);
-    }
-
-    current.remove(project);
-    projects.add(project);
-  }
-
-  ImmutableSet<BranchNameKey> getBranchesInOrder() {
-    LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
-    if (sortedBranches != null) {
-      branches.addAll(sortedBranches);
-    }
-    branches.addAll(updatedBranches);
-    return ImmutableSet.copyOf(branches);
-  }
-
-  boolean hasSubscription(BranchNameKey branch) {
-    return targets.containsKey(branch);
-  }
-
-  void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
-    branchTips.put(branch, tip);
-  }
-
-  void addOp(BatchUpdate bu, BranchNameKey branch) {
-    bu.addRepoOnlyOp(new GitlinkOp(branch));
   }
 }
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
new file mode 100644
index 0000000..ad16cb0
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -0,0 +1,384 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableList;
+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.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.entities.SubscribeSection;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+
+/**
+ * A container which stores subscription relationship. A SubscriptionGraph is calculated every time
+ * changes are pushed. Some branches are updated in these changes, and if these branches are
+ * subscribed by other projects, SubscriptionGraph would record information about these updated
+ * branches and branches/projects affected.
+ */
+public class SubscriptionGraph {
+  /** Branches updated as part of the enclosing submit or push batch. */
+  private final ImmutableSet<BranchNameKey> updatedBranches;
+
+  /**
+   * All branches affected, including those in superprojects and submodules, sorted by submodule
+   * traversal order. To support nested subscriptions, GitLink commits need to be updated in order.
+   * The closer to topological "leaf", the earlier a commit should be updated.
+   *
+   * <p>For example, there are three projects, top level project p1 subscribed to p2, p2 subscribed
+   * to bottom level project p3. When submit a change for p3. We need update both p2 and p1. To be
+   * more precise, we need update p2 first and then update p1.
+   */
+  private final ImmutableSet<BranchNameKey> sortedBranches;
+
+  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
+  private final ImmutableSetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+
+  /**
+   * Multimap of superproject name to all branch names within that superproject which have submodule
+   * subscriptions.
+   */
+  private final ImmutableSetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+
+  /** All branches subscribed by other projects. */
+  private final ImmutableSet<BranchNameKey> subscribedBranches;
+
+  public SubscriptionGraph(
+      Set<BranchNameKey> updatedBranches,
+      SetMultimap<BranchNameKey, SubmoduleSubscription> targets,
+      SetMultimap<Project.NameKey, BranchNameKey> branchesByProject,
+      Set<BranchNameKey> subscribedBranches,
+      Set<BranchNameKey> sortedBranches) {
+    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
+    this.targets = ImmutableSetMultimap.copyOf(targets);
+    this.branchesByProject = ImmutableSetMultimap.copyOf(branchesByProject);
+    this.subscribedBranches = ImmutableSet.copyOf(subscribedBranches);
+    this.sortedBranches = ImmutableSet.copyOf(sortedBranches);
+  }
+
+  /** Returns an empty {@code SubscriptionGraph}. */
+  static SubscriptionGraph createEmptyGraph(Set<BranchNameKey> updatedBranches) {
+    return new SubscriptionGraph(
+        updatedBranches,
+        ImmutableSetMultimap.of(),
+        ImmutableSetMultimap.of(),
+        ImmutableSet.of(),
+        ImmutableSet.of());
+  }
+
+  /** Get branches updated as part of the enclosing submit or push batch. */
+  public ImmutableSet<BranchNameKey> getUpdatedBranches() {
+    return updatedBranches;
+  }
+
+  /** Get all superprojects affected. */
+  public ImmutableSet<Project.NameKey> getAffectedSuperProjects() {
+    return branchesByProject.keySet();
+  }
+
+  /** See if a {@code project} is a superproject affected. */
+  boolean isAffectedSuperProject(Project.NameKey project) {
+    return branchesByProject.containsKey(project);
+  }
+
+  /**
+   * Returns all branches within the superproject {@code project} which have submodule
+   * subscriptions.
+   */
+  public ImmutableSet<BranchNameKey> getAffectedSuperBranches(Project.NameKey project) {
+    return branchesByProject.get(project);
+  }
+
+  /**
+   * Get all affected branches, including the submodule branches and superproject branches, sorted
+   * by traversal order.
+   *
+   * @see SubscriptionGraph#sortedBranches
+   */
+  public ImmutableSet<BranchNameKey> getSortedSuperprojectAndSubmoduleBranches() {
+    return sortedBranches;
+  }
+
+  /** Check if a {@code branch} is a submodule of a superproject. */
+  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
+  public boolean hasSuperproject(BranchNameKey branch) {
+    return subscribedBranches.contains(branch);
+  }
+
+  /** See if a {@code branch} is a superproject branch affected. */
+  public boolean hasSubscription(BranchNameKey branch) {
+    return targets.containsKey(branch);
+  }
+
+  /** Get all related {@code SubmoduleSubscription}s whose super branch is {@code branch}. */
+  public ImmutableSet<SubmoduleSubscription> getSubscriptions(BranchNameKey branch) {
+    return targets.get(branch);
+  }
+
+  public interface Factory {
+    SubscriptionGraph compute(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
+        throws SubmoduleConflictException;
+  }
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(Factory.class).annotatedWith(VanillaSubscriptionGraph.class).to(DefaultFactory.class);
+    }
+  }
+
+  public static class DefaultFactory implements Factory {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+    private final ProjectCache projectCache;
+    private final GitModules.Factory gitmodulesFactory;
+
+    @Inject
+    DefaultFactory(GitModules.Factory gitmodulesFactory, ProjectCache projectCache) {
+      this.gitmodulesFactory = gitmodulesFactory;
+      this.projectCache = projectCache;
+    }
+
+    @Override
+    public SubscriptionGraph compute(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
+        throws SubmoduleConflictException {
+      Map<BranchNameKey, GitModules> branchGitModules = new HashMap<>();
+      // All affected branches, including those in superprojects and submodules.
+      Set<BranchNameKey> affectedBranches = new HashSet<>();
+
+      // See SubscriptionGraph#targets.
+      SetMultimap<BranchNameKey, SubmoduleSubscription> targets =
+          MultimapBuilder.hashKeys().hashSetValues().build();
+
+      // See SubscriptionGraph#branchesByProject.
+      SetMultimap<Project.NameKey, BranchNameKey> branchesByProject =
+          MultimapBuilder.hashKeys().hashSetValues().build();
+
+      // See SubscriptionGraph#subscribedBranches.
+      Set<BranchNameKey> subscribedBranches = new HashSet<>();
+
+      Set<BranchNameKey> sortedBranches =
+          calculateSubscriptionMaps(
+              updatedBranches,
+              affectedBranches,
+              targets,
+              branchesByProject,
+              subscribedBranches,
+              branchGitModules,
+              orm);
+
+      return new SubscriptionGraph(
+          updatedBranches, targets, branchesByProject, subscribedBranches, sortedBranches);
+    }
+
+    /**
+     * Calculate the internal maps used by the operation.
+     *
+     * <p>In addition to the return value, the following fields are populated as a side effect:
+     *
+     * <ul>
+     *   <li>{@code affectedBranches}
+     *   <li>{@code targets}
+     *   <li>{@code branchesByProject}
+     *   <li>{@code subscribedBranches}
+     * </ul>
+     *
+     * @return the ordered set to be stored in {@link #sortedBranches}.
+     */
+    private Set<BranchNameKey> calculateSubscriptionMaps(
+        Set<BranchNameKey> updatedBranches,
+        Set<BranchNameKey> affectedBranches,
+        SetMultimap<BranchNameKey, SubmoduleSubscription> targets,
+        SetMultimap<Project.NameKey, BranchNameKey> branchesByProject,
+        Set<BranchNameKey> subscribedBranches,
+        Map<BranchNameKey, GitModules> branchGitModules,
+        MergeOpRepoManager orm)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Calculating superprojects - submodules map");
+      LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
+      for (BranchNameKey updatedBranch : updatedBranches) {
+        if (allVisited.contains(updatedBranch)) {
+          continue;
+        }
+
+        searchForSuperprojects(
+            updatedBranch,
+            new LinkedHashSet<>(),
+            allVisited,
+            affectedBranches,
+            targets,
+            branchesByProject,
+            subscribedBranches,
+            branchGitModules,
+            orm);
+      }
+
+      // Since the searchForSuperprojects will add all branches (related or
+      // unrelated) and ensure the superproject's branches get added first before
+      // a submodule branch. Need remove all unrelated branches and reverse
+      // the order.
+      allVisited.retainAll(affectedBranches);
+      reverse(allVisited);
+      return allVisited;
+    }
+
+    private void searchForSuperprojects(
+        BranchNameKey current,
+        LinkedHashSet<BranchNameKey> currentVisited,
+        LinkedHashSet<BranchNameKey> allVisited,
+        Set<BranchNameKey> affectedBranches,
+        SetMultimap<BranchNameKey, SubmoduleSubscription> targets,
+        SetMultimap<Project.NameKey, BranchNameKey> branchesByProject,
+        Set<BranchNameKey> subscribedBranches,
+        Map<BranchNameKey, GitModules> branchGitModules,
+        MergeOpRepoManager orm)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Now processing %s", current);
+
+      if (currentVisited.contains(current)) {
+        throw new SubmoduleConflictException(
+            "Branch level circular subscriptions detected:  "
+                + CircularPathFinder.printCircularPath(currentVisited, current));
+      }
+
+      if (allVisited.contains(current)) {
+        return;
+      }
+
+      currentVisited.add(current);
+      try {
+        Collection<SubmoduleSubscription> subscriptions =
+            superProjectSubscriptionsForSubmoduleBranch(current, branchGitModules, orm);
+        for (SubmoduleSubscription sub : subscriptions) {
+          BranchNameKey superBranch = sub.getSuperProject();
+          searchForSuperprojects(
+              superBranch,
+              currentVisited,
+              allVisited,
+              affectedBranches,
+              targets,
+              branchesByProject,
+              subscribedBranches,
+              branchGitModules,
+              orm);
+          targets.put(superBranch, sub);
+          branchesByProject.put(superBranch.project(), superBranch);
+          affectedBranches.add(superBranch);
+          affectedBranches.add(sub.getSubmodule());
+          subscribedBranches.add(sub.getSubmodule());
+        }
+      } catch (IOException e) {
+        throw new StorageException("Cannot find superprojects for " + current, e);
+      }
+      currentVisited.remove(current);
+      allVisited.add(current);
+    }
+
+    private Collection<BranchNameKey> getDestinationBranches(
+        BranchNameKey src, SubscribeSection s, MergeOpRepoManager orm) throws IOException {
+      OpenRepo or;
+      try {
+        or = orm.getRepo(s.project());
+      } catch (NoSuchProjectException e) {
+        // A project listed a non existent project to be allowed
+        // to subscribe to it. Allow this for now, i.e. no exception is
+        // thrown.
+        return s.getDestinationBranches(src, ImmutableList.of());
+      }
+
+      List<Ref> refs = or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS);
+      return s.getDestinationBranches(src, refs);
+    }
+
+    private Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
+        BranchNameKey srcBranch,
+        Map<BranchNameKey, GitModules> branchGitModules,
+        MergeOpRepoManager orm)
+        throws IOException {
+      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)
+              .orElseThrow(illegalState(srcProject))
+              .getSubscribeSections(srcBranch)) {
+        logger.atFine().log("Checking subscribe section %s", s);
+        Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s, orm);
+        for (BranchNameKey targetBranch : branches) {
+          Project.NameKey targetProject = targetBranch.project();
+          try {
+            OpenRepo or = orm.getRepo(targetProject);
+            ObjectId id = or.repo.resolve(targetBranch.branch());
+            if (id == null) {
+              logger.atFine().log("The branch %s doesn't exist.", targetBranch);
+              continue;
+            }
+          } catch (NoSuchProjectException e) {
+            logger.atFine().log("The project %s doesn't exist", targetProject);
+            continue;
+          }
+
+          GitModules m = branchGitModules.get(targetBranch);
+          if (m == null) {
+            m = gitmodulesFactory.create(targetBranch, orm);
+            branchGitModules.put(targetBranch, m);
+          }
+          ret.addAll(m.subscribedTo(srcBranch));
+        }
+      }
+      logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
+      return ret;
+    }
+
+    private static <T> void reverse(LinkedHashSet<T> set) {
+      if (set == null) {
+        return;
+      }
+
+      Deque<T> q = new ArrayDeque<>(set);
+      set.clear();
+
+      while (!q.isEmpty()) {
+        set.add(q.removeLast());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java b/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java
new file mode 100644
index 0000000..517c708
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.collect.ImmutableSet;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Sorts the projects or branches affected by the update.
+ *
+ * <p>The subscription graph contains all branches (and projects) affected by the update, but the
+ * updates must be executed in the right order, so no superproject reference is updated before its
+ * target.
+ */
+class UpdateOrderCalculator {
+
+  private final SubscriptionGraph subscriptionGraph;
+
+  UpdateOrderCalculator(SubscriptionGraph subscriptionGraph) {
+    this.subscriptionGraph = subscriptionGraph;
+  }
+
+  ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
+    LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
+    for (Project.NameKey project : subscriptionGraph.getAffectedSuperProjects()) {
+      addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
+    }
+
+    for (BranchNameKey branch : subscriptionGraph.getUpdatedBranches()) {
+      projects.add(branch.project());
+    }
+    return ImmutableSet.copyOf(projects);
+  }
+
+  private void addAllSubmoduleProjects(
+      Project.NameKey project,
+      LinkedHashSet<Project.NameKey> current,
+      LinkedHashSet<Project.NameKey> projects)
+      throws SubmoduleConflictException {
+    if (current.contains(project)) {
+      throw new SubmoduleConflictException(
+          "Project level circular subscriptions detected:  "
+              + CircularPathFinder.printCircularPath(current, project));
+    }
+
+    if (projects.contains(project)) {
+      return;
+    }
+
+    current.add(project);
+    Set<Project.NameKey> subprojects = new HashSet<>();
+    for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
+      Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
+      for (SubmoduleSubscription s : subscriptions) {
+        subprojects.add(s.getSubmodule().project());
+      }
+    }
+
+    for (Project.NameKey p : subprojects) {
+      addAllSubmoduleProjects(p, current, projects);
+    }
+
+    current.remove(project);
+    projects.add(project);
+  }
+
+  ImmutableSet<BranchNameKey> getBranchesInOrder() {
+    LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
+    branches.addAll(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches());
+    branches.addAll(subscriptionGraph.getUpdatedBranches());
+    return ImmutableSet.copyOf(branches);
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/VanillaSubscriptionGraph.java b/java/com/google/gerrit/server/submit/VanillaSubscriptionGraph.java
new file mode 100644
index 0000000..a88157e
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/VanillaSubscriptionGraph.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on a {@link SubscriptionGraph.Factory} without gerrit configuration.
+ *
+ * <p>See {@link ConfiguredSubscriptionGraphFactory}.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface VanillaSubscriptionGraph {}
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 166e88d..7fdf833 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -20,6 +20,7 @@
 import static com.google.common.flogger.LazyArgs.lazy;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toMap;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Throwables;
@@ -33,6 +34,7 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSet.Id;
@@ -82,6 +84,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
 
 /**
  * Helper for a set of change updates that should be applied to the NoteDb database.
@@ -121,9 +124,9 @@
   }
 
   public static void execute(
-      Collection<BatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
+      Collection<BatchUpdate> updates, ImmutableList<BatchUpdateListener> listeners, boolean dryrun)
       throws UpdateException, RestApiException {
-    requireNonNull(listener);
+    requireNonNull(listeners);
     if (updates.isEmpty()) {
       return;
     }
@@ -137,16 +140,16 @@
         for (BatchUpdate u : updates) {
           u.executeUpdateRepo();
         }
-        listener.afterUpdateRepos();
+        notifyAfterUpdateRepo(listeners);
         for (BatchUpdate u : updates) {
-          changesHandles.add(u.executeChangeOps(dryrun));
+          changesHandles.add(u.executeChangeOps(listeners, dryrun));
         }
         for (ChangesHandle h : changesHandles) {
           h.execute();
           indexFutures.addAll(h.startIndexFutures());
         }
-        listener.afterUpdateRefs();
-        listener.afterUpdateChanges();
+        notifyAfterUpdateRefs(listeners);
+        notifyAfterUpdateChanges(listeners);
       } finally {
         for (ChangesHandle h : changesHandles) {
           h.close();
@@ -158,10 +161,7 @@
       // Fire ref update events only after all mutations are finished, since callers may assume a
       // patch set ref being created means the change was created, or a branch advancing meaning
       // some changes were closed.
-      updates.stream()
-          .filter(u -> u.batchRefUpdate != null)
-          .forEach(
-              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+      updates.forEach(BatchUpdate::fireRefChangeEvent);
 
       if (!dryrun) {
         for (BatchUpdate u : updates) {
@@ -173,6 +173,27 @@
     }
   }
 
+  private static void notifyAfterUpdateRepo(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateRepos();
+    }
+  }
+
+  private static void notifyAfterUpdateRefs(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateRefs();
+    }
+  }
+
+  private static void notifyAfterUpdateChanges(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateChanges();
+    }
+  }
+
   private static void checkDifferentProject(Collection<BatchUpdate> updates) {
     Multiset<Project.NameKey> projectCounts =
         updates.stream().map(u -> u.project).collect(toImmutableMultiset());
@@ -346,6 +367,7 @@
 
   private RepoView repoView;
   private BatchRefUpdate batchRefUpdate;
+  private boolean executed;
   private OnSubmitValidators onSubmitValidators;
   private PushCertificate pushCert;
   private String refLogMessage;
@@ -383,11 +405,15 @@
   }
 
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, false);
+    execute(ImmutableList.of(this), ImmutableList.of(listener), false);
   }
 
   public void execute() throws UpdateException, RestApiException {
-    execute(BatchUpdateListener.NONE);
+    execute(ImmutableList.of(this), ImmutableList.of(), false);
+  }
+
+  public boolean isExecuted() {
+    return executed;
   }
 
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
@@ -441,6 +467,10 @@
     return this;
   }
 
+  public Project.NameKey getProject() {
+    return project;
+  }
+
   private void initRepository() throws IOException {
     if (repoView == null) {
       repoView = new RepoView(repoManager, project);
@@ -462,6 +492,17 @@
     return repoView != null ? repoView.getCommands().getCommands() : ImmutableMap.of();
   }
 
+  /**
+   * Return the references successfully updated by this BatchUpdate with their command. In dryrun,
+   * we assume all updates were successful.
+   */
+  public Map<BranchNameKey, ReceiveCommand> getSuccessfullyUpdatedBranches(boolean dryrun) {
+    return getRefUpdates().entrySet().stream()
+        .filter(entry -> dryrun || entry.getValue().getResult() == Result.OK)
+        .collect(
+            toMap(entry -> BranchNameKey.create(project, entry.getKey()), Map.Entry::getValue));
+  }
+
   public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
     checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
     requireNonNull(op);
@@ -516,6 +557,12 @@
     }
   }
 
+  private void fireRefChangeEvent() {
+    if (batchRefUpdate != null) {
+      gitRefUpdated.fire(project, batchRefUpdate, getAccount().orElse(null));
+    }
+  }
+
   private class ChangesHandle implements AutoCloseable {
     private final NoteDbUpdateManager manager;
     private final boolean dryrun;
@@ -539,6 +586,7 @@
 
     void execute() throws IOException {
       BatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
+      BatchUpdate.this.executed = manager.isExecuted();
     }
 
     List<ListenableFuture<?>> startIndexFutures() {
@@ -566,7 +614,8 @@
     }
   }
 
-  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
+  private ChangesHandle executeChangeOps(
+      ImmutableList<BatchUpdateListener> batchUpdateListeners, boolean dryrun) throws Exception {
     logDebug("Executing change ops");
     initRepository();
     Repository repo = repoView.getRepository();
@@ -579,6 +628,7 @@
         new ChangesHandle(
             updateManagerFactory
                 .create(project)
+                .setBatchUpdateListeners(batchUpdateListeners)
                 .setChangeRepo(
                     repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
             dryrun);
diff --git a/java/com/google/gerrit/server/update/BatchUpdateListener.java b/java/com/google/gerrit/server/update/BatchUpdateListener.java
index 765bba1..d286e84 100644
--- a/java/com/google/gerrit/server/update/BatchUpdateListener.java
+++ b/java/com/google/gerrit/server/update/BatchUpdateListener.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.update;
 
+import org.eclipse.jgit.lib.BatchRefUpdate;
+
 /**
  * Interface for listening during batch update execution.
  *
@@ -21,11 +23,25 @@
  * after that phase has been completed for <em>all</em> updates.
  */
 public interface BatchUpdateListener {
-  public static final BatchUpdateListener NONE = new BatchUpdateListener() {};
+  BatchUpdateListener NONE = new BatchUpdateListener() {};
 
   /** Called after updating all repositories and flushing objects but before updating any refs. */
   default void afterUpdateRepos() throws Exception {}
 
+  /**
+   * Optional setup of the {@link BatchRefUpdate} that is going to be executed.
+   *
+   * <p>Called after {@link #afterUpdateRepos()}, before {@link #afterUpdateRefs()} and {@link
+   * #afterUpdateChanges()}
+   *
+   * @param bru a batch ref update, ready but not executed yet
+   * @return a new {@link BatchRefUpdate}. Implementations can decide to modify and return the
+   *     incoming instance, but callers must not rely on that.
+   */
+  default BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+    return bru;
+  }
+
   /** Called after updating all refs. */
   default void afterUpdateRefs() throws Exception {}
 
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
new file mode 100644
index 0000000..5a3a789
--- /dev/null
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.collect.ImmutableList;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.submit.MergeOpRepoManager;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+public class SubmissionExecutor {
+
+  private final ImmutableList<SubmissionListener> submissionListeners;
+  private final boolean dryrun;
+  private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
+
+  public SubmissionExecutor(
+      boolean dryrun, SubmissionListener listener, SubmissionListener... otherListeners) {
+    this.dryrun = dryrun;
+    this.submissionListeners =
+        ImmutableList.<SubmissionListener>builder()
+            .add(listener)
+            .addAll(Arrays.asList(otherListeners))
+            .build();
+    if (dryrun) {
+      submissionListeners.forEach(SubmissionListener::setDryrun);
+    }
+  }
+
+  /**
+   * Set additional listeners. These can be set again in each try (or will be reused if not
+   * overwritten).
+   */
+  public void setAdditionalBatchUpdateListeners(
+      ImmutableList<BatchUpdateListener> additionalListeners) {
+    this.additionalListeners = additionalListeners;
+  }
+
+  /** Execute the batch updates, reporting to all the Submission and BatchUpdateListeners. */
+  public void execute(Collection<BatchUpdate> updates) throws RestApiException, UpdateException {
+    submissionListeners.forEach(l -> l.beforeBatchUpdates(updates));
+
+    ImmutableList<BatchUpdateListener> listeners =
+        new ImmutableList.Builder<BatchUpdateListener>()
+            .addAll(additionalListeners)
+            .addAll(
+                submissionListeners.stream()
+                    .map(l -> l.listensToBatchUpdates())
+                    .filter(Optional::isPresent)
+                    .map(Optional::get)
+                    .collect(Collectors.toList()))
+            .build();
+    BatchUpdate.execute(updates, listeners, dryrun);
+  }
+
+  /**
+   * Caller invokes this when done with the submission (either because everything succeeded or gave
+   * up retrying).
+   */
+  public void afterExecutions(MergeOpRepoManager orm) {
+    submissionListeners.forEach(l -> l.afterSubmission(orm));
+  }
+}
diff --git a/java/com/google/gerrit/server/update/SubmissionListener.java b/java/com/google/gerrit/server/update/SubmissionListener.java
new file mode 100644
index 0000000..0df8491
--- /dev/null
+++ b/java/com/google/gerrit/server/update/SubmissionListener.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.update;
+
+import com.google.gerrit.server.submit.MergeOpRepoManager;
+import java.util.Collection;
+import java.util.Optional;
+
+/**
+ * Status and progress of a submission.
+ *
+ * <p>{@link SubmissionExecutor} reports the progress of the submission through this interface. An
+ * instance is reused between retries but should not be reused for different submissions.
+ */
+public interface SubmissionListener {
+
+  /**
+   * This submission is a dryrun.
+   *
+   * <p>In dryrun, the submission adds objects to storage, generates receive commands and creates a
+   * BatchRefUpdate, but it won't execute the BRU (i.e. it won't update the refs).
+   *
+   * <p>The submission receives the listeners and the dryrun flag at construction time. This method
+   * is called if needed at that point (i.e. before anything else) and never again inside the
+   * submission. Listeners instances should not be reused between submissions (note that the dryrun
+   * state would not be reverted).
+   */
+  void setDryrun();
+
+  /**
+   * Submission will execute these updates.
+   *
+   * <p>The BatchUpdates haven't execute anything yet.
+   *
+   * <p>This method is called once per submission try. The retry calls can have only a subset of the
+   * BatchUpdates (what failed in the previous attempt). On retries the BatchUpdates are not reused.
+   * Implementations must store intermediate results if needed on {@link
+   * #afterSubmission(MergeOpRepoManager)}.
+   *
+   * @param updates updates to execute in this try of the submission. Implementations should not
+   *     modify them.
+   */
+  void beforeBatchUpdates(Collection<BatchUpdate> updates);
+
+  /**
+   * Submission completed (either success or giving up retrying).
+   *
+   * <p>This is called after all (successfull) updates have been committed to storage and there
+   * won't be more retries.
+   *
+   * @param orm the orm to use if the after submission steps need to read from the repositories.
+   *     This could be a pristine repo manager (if the previous op didn't use MergeOpRepoManager) or
+   *     the latest orm used after retrying.
+   */
+  void afterSubmission(MergeOpRepoManager orm);
+
+  /**
+   * If the submission needs to know more about the BatchUpdate execution, it can provide a {@link
+   * BatchUpdateListener}.
+   *
+   * @return a BatchUpdateListener
+   */
+  Optional<BatchUpdateListener> listensToBatchUpdates();
+}
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateOnSubmission.java b/java/com/google/gerrit/server/update/SuperprojectUpdateOnSubmission.java
new file mode 100644
index 0000000..441132c
--- /dev/null
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateOnSubmission.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.server.update;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/** Marker on a {@link SubmissionListener} that updates the superprojects on submission. */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface SuperprojectUpdateOnSubmission {}
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
new file mode 100644
index 0000000..dffdff0
--- /dev/null
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.submit.MergeOpRepoManager;
+import com.google.gerrit.server.submit.SubmoduleOp;
+import com.google.inject.AbstractModule;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import javax.inject.Inject;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Update superprojects after submission is done */
+public class SuperprojectUpdateSubmissionListener implements SubmissionListener {
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final SubmoduleOp.Factory subOpFactory;
+  private final Map<BranchNameKey, ReceiveCommand> updatedBranches = new HashMap<>();
+  private ImmutableList<BatchUpdate> batchUpdates = ImmutableList.of();
+  private boolean dryrun;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(SubmissionListener.class)
+          .annotatedWith(SuperprojectUpdateOnSubmission.class)
+          .to(SuperprojectUpdateSubmissionListener.class);
+    }
+  }
+
+  @Inject
+  public SuperprojectUpdateSubmissionListener(SubmoduleOp.Factory subOpFactory) {
+    this.subOpFactory = subOpFactory;
+  }
+
+  @Override
+  public void setDryrun() {
+    this.dryrun = true;
+  }
+
+  @Override
+  public void beforeBatchUpdates(Collection<BatchUpdate> updates) {
+    if (!batchUpdates.isEmpty()) {
+      // This is a retry. Save previous updates, as they are not in the new BatchUpdate.
+      collectSuccessfullUpdates();
+    }
+    this.batchUpdates = ImmutableList.copyOf(updates);
+  }
+
+  @Override
+  public void afterSubmission(MergeOpRepoManager orm) {
+    collectSuccessfullUpdates();
+    // Update superproject gitlinks if required.
+    if (!updatedBranches.isEmpty()) {
+      try {
+        SubmoduleOp op = subOpFactory.create(updatedBranches, orm);
+        op.updateSuperProjects(dryrun);
+      } catch (RestApiException e) {
+        logger.atWarning().withCause(e).log("Can't update the superprojects");
+      }
+    }
+  }
+
+  @Override
+  public Optional<BatchUpdateListener> listensToBatchUpdates() {
+    return Optional.empty();
+  }
+
+  private void collectSuccessfullUpdates() {
+    if (!this.batchUpdates.isEmpty()) {
+      for (BatchUpdate bu : batchUpdates) {
+        updatedBranches.putAll(bu.getSuccessfullyUpdatedBranches(dryrun));
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
new file mode 100644
index 0000000..56b1dda
--- /dev/null
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
+import com.google.gerrit.server.mail.send.AttentionSetSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
+import com.google.gerrit.server.update.Context;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+
+public class AttentionSetEmail implements Runnable, RequestContext {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+
+    /**
+     * factory for sending an email when adding users to the attention set or removing them from it.
+     *
+     * @param sender sender in charge of sending the email, can be {@link AddToAttentionSetSender}
+     *     or {@link RemoveFromAttentionSetSender}.
+     * @param ctx context for sending the email.
+     * @param change the change that the user was added/removed in.
+     * @param reason reason for adding/removing the user.
+     * @param messageId messageId for tracking the email.
+     * @param attentionUserId the user added/removed.
+     */
+    AttentionSetEmail create(
+        AttentionSetSender sender,
+        Context ctx,
+        Change change,
+        String reason,
+        MessageIdGenerator.MessageId messageId,
+        Account.Id attentionUserId);
+  }
+
+  private ExecutorService sendEmailsExecutor;
+  private AttentionSetSender sender;
+  private Context ctx;
+  private Change change;
+  private String reason;
+
+  private MessageIdGenerator.MessageId messageId;
+  private Account.Id attentionUserId;
+
+  @Inject
+  AttentionSetEmail(
+      @SendEmailExecutor ExecutorService executor,
+      @Assisted AttentionSetSender sender,
+      @Assisted Context ctx,
+      @Assisted Change change,
+      @Assisted String reason,
+      @Assisted MessageIdGenerator.MessageId messageId,
+      @Assisted Account.Id attentionUserId) {
+    this.sendEmailsExecutor = executor;
+    this.sender = sender;
+    this.ctx = ctx;
+    this.change = change;
+    this.reason = reason;
+    this.messageId = messageId;
+    this.attentionUserId = attentionUserId;
+  }
+
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+  }
+
+  @Override
+  public void run() {
+    try {
+      AccountState accountState =
+          ctx.getUser().isIdentifiedUser() ? ctx.getUser().asIdentifiedUser().state() : null;
+      if (accountState != null) {
+        sender.setFrom(accountState.account().id());
+      }
+      sender.setNotify(ctx.getNotify(change.getId()));
+      sender.setAttentionSetUser(attentionUserId);
+      sender.setReason(reason);
+      sender.setMessageId(messageId);
+      sender.send();
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "send-email comments";
+  }
+
+  @Override
+  public CurrentUser getUser() {
+    return ctx.getUser();
+  }
+}
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index ad2c98c..62cad3f 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -14,13 +14,17 @@
 
 package com.google.gerrit.server.util;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 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) {
@@ -29,5 +33,21 @@
         .collect(ImmutableSet.toImmutableSet());
   }
 
+  /**
+   * Validates the input for AttentionSetInput. This must be called for all inputs that relate to
+   * adding or removing attention set entries, except for {@link
+   * com.google.gerrit.server.restapi.change.RemoveFromAttentionSet}.
+   */
+  public static void validateInput(AttentionSetInput input) throws BadRequestException {
+    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");
+    }
+  }
+
   private AttentionSetUtil() {}
 }
diff --git a/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
index a03c1f2..038fe2c 100644
--- a/java/com/google/gerrit/server/util/LabelVote.java
+++ b/java/com/google/gerrit/server/util/LabelVote.java
@@ -18,7 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 
 /** A single vote on a label, consisting of a label name and a value. */
 @AutoValue
diff --git a/java/com/google/gerrit/server/util/MostSpecificComparator.java b/java/com/google/gerrit/server/util/MostSpecificComparator.java
index b22617c..ac33902 100644
--- a/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.server.project.RefPattern;
 import java.util.Comparator;
 import org.apache.commons.lang.StringUtils;
diff --git a/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java b/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
index 996ad87..76034ce 100644
--- a/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
+++ b/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.validators;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import java.util.Map;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 4a1489739..92019ad 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -116,7 +116,7 @@
   }
 
   private List<ChangeNotes> changeFromNotesFactory(String id) throws UnloggedFailure {
-    return changeNotesFactory.create(parseId(id));
+    return changeNotesFactory.createUsingIndexLookup(parseId(id));
   }
 
   private List<Change.Id> parseId(String id) throws UnloggedFailure {
diff --git a/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
index 340b910..59f3f0c 100644
--- a/java/com/google/gerrit/sshd/SshScope.java
+++ b/java/com/google/gerrit/sshd/SshScope.java
@@ -43,6 +43,8 @@
     volatile long started;
     volatile long finished;
 
+    private IdentifiedUser identifiedUser;
+
     private Context(SshSession s, String c, long at) {
       session = s;
       commandLine = c;
@@ -68,8 +70,10 @@
     public CurrentUser getUser() {
       CurrentUser user = session.getUser();
       if (user != null && user.isIdentifiedUser()) {
-        IdentifiedUser identifiedUser = userFactory.create(user.getAccountId());
-        identifiedUser.setAccessPath(user.getAccessPath());
+        if (identifiedUser == null) {
+          identifiedUser = userFactory.create(user.getAccountId());
+          identifiedUser.setAccessPath(user.getAccessPath());
+        }
         return identifiedUser;
       }
       return user;
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index 3269c2b..37f4245 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -79,7 +79,7 @@
         return;
       }
 
-      List<AccountInfo> members = getDirectMembers(group.get());
+      List<AccountInfo> members = getMembers(group.get());
       ColumnFormatter formatter = new ColumnFormatter(writer, '\t');
       formatter.addColumn("id");
       formatter.addColumn("username");
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 541bf52..b58cc45 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -22,8 +22,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.CharStreams;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.GerritApi;
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index a60995b..fec9b27 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -21,9 +21,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.send.EmailSender;
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 8800463..1779a18 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.testing;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
-import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
 import com.google.gerrit.extensions.client.AuthType;
@@ -30,6 +31,7 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -85,6 +87,8 @@
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.ssh.NoSshKeyCache;
 import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
+import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener;
 import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
@@ -173,6 +177,8 @@
     install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
     install(new AuditModule());
+    install(new SubscriptionGraph.Module());
+    install(new SuperprojectUpdateSubmissionListener.Module());
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
@@ -209,7 +215,7 @@
           @Singleton
           @DiffExecutor
           public ExecutorService createDiffExecutor() {
-            return MoreExecutors.newDirectExecutorService();
+            return newDirectExecutorService();
           }
         });
     install(new DefaultMemoryCacheModule());
@@ -277,7 +283,7 @@
   @Singleton
   @SendEmailExecutor
   public ExecutorService createSendEmailExecutor() {
-    return MoreExecutors.newDirectExecutorService();
+    return newDirectExecutorService();
   }
 
   @Provides
@@ -287,6 +293,13 @@
     return queues.createQueue(2, "FanOut");
   }
 
+  @Provides
+  @Singleton
+  @CacheRefreshExecutor
+  public ListeningExecutorService createCacheRefreshExecutor() {
+    return newDirectExecutorService();
+  }
+
   private Module luceneIndexModule() {
     return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
   }
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index 5ce6d13..5865a3c 100644
--- a/java/com/google/gerrit/testing/TestCommentHelper.java
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -26,6 +27,7 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import java.util.Collection;
@@ -45,6 +47,10 @@
     return populate(new DraftInput(), "file", message);
   }
 
+  public DraftInput newDraft(String message, String inReplyTo) {
+    return populate(new DraftInput(), "file", createLineRange(), message, inReplyTo);
+  }
+
   public DraftInput newDraft(String path, Side side, int line, String message) {
     DraftInput d = new DraftInput();
     return populate(d, path, side, line, message);
@@ -54,24 +60,29 @@
     gApi.changes().id(changeId).revision(revId).createDraft(in);
   }
 
+  public void addDraft(String changeId, DraftInput in) throws Exception {
+    gApi.changes().id(changeId).current().createDraft(in);
+  }
+
   public Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
-    return gApi.changes().id(changeId).comments().values().stream()
+    return gApi.changes().id(changeId).commentsRequest().get().values().stream()
         .flatMap(Collection::stream)
         .collect(toList());
   }
 
   public static <C extends Comment> C populate(C c, String path, String message) {
-    return populate(c, path, createLineRange(), message);
+    return populate(c, path, createLineRange(), message, null);
   }
 
-  private static <C extends Comment> C populate(C c, String path, Range range, String message) {
+  private static <C extends Comment> C populate(
+      C c, String path, Range range, String message, String inReplyTo) {
     int line = range.startLine;
     c.path = path;
     c.side = Side.REVISION;
     c.parent = null;
     c.line = line != 0 ? line : null;
     c.message = message;
-    c.unresolved = false;
+    c.inReplyTo = inReplyTo;
     if (line != 0) c.range = range;
     return c;
   }
@@ -84,7 +95,6 @@
     c.parent = null;
     c.line = line != 0 ? line : null;
     c.message = message;
-    c.unresolved = false;
     if (line != 0) c.range = range;
     return c;
   }
@@ -116,7 +126,6 @@
     RobotCommentInput in = new RobotCommentInput();
     in.robotId = "happyRobot";
     in.robotRunId = "1";
-    in.line = 1;
     in.message = "nit: trailing whitespace";
     in.path = path;
     return in;
@@ -138,12 +147,30 @@
     addRobotComment(targetChangeId, robotCommentInput, "robot comment test");
   }
 
+  public void addRobotComment(Change.Id targetChangeId, RobotCommentInput robotCommentInput)
+      throws Exception {
+    addRobotComment(targetChangeId, robotCommentInput, "robot comment test");
+  }
+
   public void addRobotComment(
       String targetChangeId, RobotCommentInput robotCommentInput, String message) throws Exception {
+    ReviewInput reviewInput = createReviewInput(robotCommentInput, message);
+    gApi.changes().id(targetChangeId).current().review(reviewInput);
+  }
+
+  public void addRobotComment(
+      Change.Id targetChangeId, RobotCommentInput robotCommentInput, String message)
+      throws Exception {
+    ReviewInput reviewInput = createReviewInput(robotCommentInput, message);
+    gApi.changes().id(targetChangeId.get()).current().review(reviewInput);
+  }
+
+  private ReviewInput createReviewInput(RobotCommentInput robotCommentInput, String message) {
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.robotComments =
         Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
     reviewInput.message = message;
-    gApi.changes().id(targetChangeId).current().review(reviewInput);
+    reviewInput.tag = ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX;
+    return reviewInput;
   }
 }
diff --git a/java/com/google/gerrit/truth/MapSubject.java b/java/com/google/gerrit/truth/MapSubject.java
index 8217920..4eba753 100644
--- a/java/com/google/gerrit/truth/MapSubject.java
+++ b/java/com/google/gerrit/truth/MapSubject.java
@@ -1,24 +1,23 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the 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.
 
 package com.google.gerrit.truth;
 
 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.IterableSubject;
 import com.google.common.truth.Subject;
 import java.util.Map;
@@ -53,4 +52,9 @@
     isNotNull();
     return check("values()").that(map.values());
   }
+
+  public IntegerSubject size() {
+    isNotNull();
+    return check("size()").that(map.size());
+  }
 }
diff --git a/java/com/google/gerrit/util/logging/LogTimestampFormatter.java b/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
index 9637b8b..cf071de 100644
--- a/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
+++ b/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
@@ -24,7 +24,7 @@
 
 /** Formatter for timestamps used in log entries. */
 public class LogTimestampFormatter {
-  public static final String TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
+  public static final String TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
 
   private final DateTimeFormatter dateFormatter;
   private final ZoneOffset timeOffset;
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 90a2cbf..5ee292ff 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -2,8 +2,8 @@
 
 package gerrit;
 
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.StoredValues;
diff --git a/java/gerrit/PRED_files_1.java b/java/gerrit/PRED_files_1.java
new file mode 100644
index 0000000..ac45449
--- /dev/null
+++ b/java/gerrit/PRED_files_1.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 gerrit;
+
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.ListTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
+
+/** Exports list of Strings that each represent a file name in the current patchset. */
+public class PRED_files_1 extends Predicate.P1 {
+  private static final SymbolTerm file = SymbolTerm.intern("file", 3);
+
+  PRED_files_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+    Term listHead = Prolog.Nil;
+
+    try (RevWalk revWalk = new RevWalk(StoredValues.REPOSITORY.get(engine))) {
+      RevCommit commit = revWalk.parseCommit(StoredValues.getPatchSet(engine).commitId());
+      List<PatchListEntry> patches = StoredValues.PATCH_LIST.get(engine).getPatches();
+      Set<String> submodules =
+          getAllSubmodulePaths(StoredValues.REPOSITORY.get(engine), commit, patches);
+      for (PatchListEntry entry : patches) {
+        if (Patch.isMagic(entry.getNewName())) {
+          continue;
+        }
+        SymbolTerm fileNameTerm = SymbolTerm.create(entry.getNewName());
+        SymbolTerm changeType = SymbolTerm.create(entry.getChangeType().getCode());
+        SymbolTerm fileType;
+        if (submodules.contains(entry.getNewName())) {
+          fileType = SymbolTerm.create("SUBMODULE");
+        } else {
+          fileType = SymbolTerm.create("REGULAR");
+        }
+        listHead =
+            new ListTerm(new StructureTerm(file, fileNameTerm, changeType, fileType), listHead);
+      }
+    } catch (IOException ex) {
+      return engine.fail();
+    }
+    if (!a1.unify(listHead, engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+
+  /** Returns the paths for all {@code GITLINK} files. */
+  private static Set<String> getAllSubmodulePaths(
+      Repository repository, RevCommit commit, List<PatchListEntry> patches)
+      throws PrologException, IOException {
+    Set<String> submodules = new HashSet<>();
+    try (TreeWalk treeWalk = new TreeWalk(repository)) {
+      treeWalk.addTree(commit.getTree());
+      Set<String> allPaths =
+          patches.stream()
+              .map(PatchListEntry::getNewName)
+              .filter(f -> !Patch.isMagic(f))
+              .collect(Collectors.toSet());
+      treeWalk.setFilter(PathFilterGroup.createFromStrings(allPaths));
+
+      while (treeWalk.next()) {
+        if (treeWalk.getFileMode() == FileMode.GITLINK) {
+          submodules.add(treeWalk.getPathString());
+        }
+      }
+      return submodules;
+    }
+  }
+}
diff --git a/java/gerrit/PRED_get_legacy_label_types_1.java b/java/gerrit/PRED_get_legacy_label_types_1.java
index 2f0c1ea..dfed17b 100644
--- a/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -14,8 +14,8 @@
 
 package gerrit;
 
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
diff --git a/javatests/com/google/gerrit/acceptance/DaemonOverridesTestLibModulesIT.java b/javatests/com/google/gerrit/acceptance/DaemonOverridesTestLibModulesIT.java
new file mode 100644
index 0000000..f3a2324
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/DaemonOverridesTestLibModulesIT.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.ModuleImpl;
+import com.google.gerrit.server.audit.AuditModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
+import org.junit.Test;
+
+public class DaemonOverridesTestLibModulesIT extends AbstractDaemonTest {
+  private static final String TEST_MODULE = "test-module";
+
+  @Inject
+  @Named(value = TEST_MODULE)
+  private String testModuleClassName;
+
+  public abstract static class TestModule extends AuditModule {
+    @Override
+    protected void configure() {
+      super.configure();
+      bind(String.class).annotatedWith(Names.named(TEST_MODULE)).toInstance(getClass().getName());
+    }
+  }
+
+  @ModuleImpl(name = TEST_MODULE)
+  public static class DefaultModule extends TestModule {}
+
+  @ModuleImpl(name = TEST_MODULE)
+  public static class OverriddenModule extends TestModule {}
+
+  @Override
+  public Module createAuditModule() {
+    return new DefaultModule();
+  }
+
+  @Override
+  public Module createModule() {
+    return new OverriddenModule();
+  }
+
+  @Test
+  public void testSysModuleShouldOverrideTheDefaultOneWithSameModuleAnnotation() {
+    assertThat(testModuleClassName).isEqualTo(OverriddenModule.class.getName());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
new file mode 100644
index 0000000..30f1dcb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.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.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.testing.FakeEmailSender;
+import java.net.URL;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class OutgoingEmailIT extends AbstractDaemonTest {
+
+  @Test
+  public void messageIdHeaderFromChangeUpdate() throws Exception {
+    Repository repository = repoManager.openRepository(project);
+    PushOneCommit.Result result = createChange();
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).abandon();
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            withPrefixAndSuffixForMessageId(
+                repository
+                        .getRefDatabase()
+                        .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).restore();
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            withPrefixAndSuffixForMessageId(
+                repository
+                        .getRefDatabase()
+                        .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "auth.registerEmailPrivateKey",
+      value = "HsOc6l_2lhS9G7sE_RsnS7Z6GJjdRDX14co=")
+  public void messageIdHeaderFromAccountUpdate() throws Exception {
+    Repository allUsersRepo = repoManager.openRepository(allUsers);
+    String email = "new.email@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    sender.clear();
+    gApi.accounts().self().addEmail(input);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(Address.create(email));
+
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            withPrefixAndSuffixForMessageId(
+                allUsersRepo
+                        .getRefDatabase()
+                        .exactRef(RefNames.refsUsers(admin.id()))
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
+  }
+
+  @Test
+  public void messageIdHeaderFromPasswordUpdate() throws Exception {
+    sender.clear();
+    String newPassword = gApi.accounts().self().generateHttpPassword();
+    assertThat(newPassword).isNotNull();
+    assertThat(getMessageId(sender))
+        .containsMatch("<HTTP_password_change-" + admin.id().toString() + ".*@.*>");
+  }
+
+  @Test
+  public void htmlAndPlainTextSuffixAddedToMessageId() throws Exception {
+    PushOneCommit.Result result = createChange();
+    GeneralPreferencesInfo generalPreferencesInfo = new GeneralPreferencesInfo();
+    generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.PLAINTEXT;
+    gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    assertThat(getMessageId(sender)).contains("-PLAIN");
+    sender.clear();
+
+    generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT;
+    gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.reject());
+    assertThat(getMessageId(sender)).contains("-HTML");
+  }
+
+  private static String getMessageId(FakeEmailSender sender) {
+    return ((EmailHeader.String)
+            (Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID")))
+        .getString();
+  }
+
+  // Each message-id must start with '<' and end with '>'. Also, it must contain no spaces and it
+  // must contain a '@'.
+  private String withPrefixAndSuffixForMessageId(String id) throws Exception {
+    return "<" + id + "@" + new URL(canonicalWebUrl.get()).getHost() + ">";
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
index 448629c..538009a 100644
--- a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
+++ b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
@@ -16,8 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.account.UniversalGroupBackend;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.inject.Inject;
@@ -67,4 +69,16 @@
     testGroupBackend.create(testUUID);
     assertThat(testGroupBackend.get(testUUID)).isNotNull();
   }
+
+  @Test
+  public void returnsMembershipsForUser() throws Exception {
+    testGroupBackend.create(testUUID);
+    testGroupBackend.setMembershipsOf(
+        admin.id(), new ListGroupMembership(ImmutableList.of(testUUID)));
+    assertThat(
+            testGroupBackend
+                .membershipsOf(identifiedUserFactory.create(admin.id()))
+                .getKnownGroups())
+        .containsExactly(testUUID);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index f9ba8a2..1b55652 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -76,15 +76,16 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.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.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -122,7 +123,6 @@
 import com.google.gerrit.gpg.PublicKeyStore;
 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;
@@ -139,7 +139,6 @@
 import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
@@ -148,6 +147,7 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.jcraft.jsch.KeyPair;
@@ -161,7 +161,6 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -284,12 +283,16 @@
       String labelName,
       int min,
       int max) {
-    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
-    AccessSection accessSection = cfg.getAccessSection(ref);
-    assertThat(accessSection).isNotNull();
+    Optional<AccessSection> accessSection =
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .getConfig()
+            .getAccessSection(ref);
+    assertThat(accessSection).isPresent();
 
     String permissionName = Permission.LABEL + labelName;
-    Permission permission = accessSection.getPermission(permissionName);
+    Permission permission = accessSection.get().getPermission(permissionName);
     assertPermission(permission, permissionName, exclusive, labelName);
     assertPermissionRule(
         permission.getRule(groupReference), groupReference, Action.ALLOW, false, min, max);
@@ -363,6 +366,7 @@
       assertThat(accountInfo.name).isEqualTo(input.name);
       assertThat(accountInfo.email).isEqualTo(input.email);
       assertThat(accountInfo.status).isNull();
+      assertThat(accountInfo.tags).isNull();
 
       Account.Id accountId = Account.id(accountInfo._accountId);
       accountIndexedCounter.assertReindexOf(accountId, 1);
@@ -1221,7 +1225,7 @@
   @Test
   public void cannotAddNonConfirmedEmailWithoutModifyAccountPermission() throws Exception {
     TestAccount account = accountCreator.create(name("user"));
-    EmailInput input = newEmailInput("test@test.com");
+    EmailInput input = newEmailInput("test@example.com");
     requestScopeOperations.setApiUser(user.id());
     assertThrows(AuthException.class, () -> gApi.accounts().id(account.username()).addEmail(input));
   }
@@ -1653,8 +1657,11 @@
       // remove default READ permissions
       try (ProjectConfigUpdate u = updateProject(allUsers)) {
         u.getConfig()
-            .getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
-            .remove(new Permission(Permission.READ));
+            .upsertAccessSection(
+                RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+                as -> {
+                  as.remove(Permission.builder(Permission.READ));
+                });
         u.save();
       }
 
@@ -2901,12 +2908,7 @@
   }
 
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
-    return Correspondence.from(
-        (actualGroup, expectedName) -> {
-          String groupName = actualGroup == null ? null : actualGroup.name;
-          return Objects.equals(groupName, expectedName);
-        },
-        "has name");
+    return NullAwareCorrespondence.transforming(groupInfo -> groupInfo.name, "has name");
   }
 
   private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 2b0bdf9..24b7998 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -27,11 +28,11 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -67,7 +68,8 @@
   protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = projectConfigFactory.read(md);
-      config.getProject().setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, value);
+      config.updateProject(
+          p -> p.setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, value));
       config.commit(md);
       projectCache.evictAndReindex(config.getProject());
     }
@@ -75,21 +77,21 @@
 
   protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
       throws Exception {
-    ContributorAgreement ca;
+    ContributorAgreement.Builder ca;
     String name = autoVerify ? "cla-test-group" : "cla-test-no-auto-verify-group";
     AccountGroup.UUID g = groupOperations.newGroup().name(name).create();
     GroupApi groupApi = gApi.groups().id(g.get());
     groupApi.description("CLA test group");
     InternalGroup caGroup = group(AccountGroup.uuid(groupApi.detail().id));
-    GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
-    PermissionRule rule = new PermissionRule(groupRef);
-    rule.setAction(PermissionRule.Action.ALLOW);
+    GroupReference groupRef = GroupReference.create(caGroup.getGroupUUID(), caGroup.getName());
+    PermissionRule rule =
+        PermissionRule.builder(groupRef).setAction(PermissionRule.Action.ALLOW).build();
     if (autoVerify) {
-      ca = new ContributorAgreement("cla-test");
+      ca = ContributorAgreement.builder("cla-test");
       ca.setAutoVerify(groupRef);
       ca.setAccepted(ImmutableList.of(rule));
     } else {
-      ca = new ContributorAgreement("cla-test-no-auto-verify");
+      ca = ContributorAgreement.builder("cla-test-no-auto-verify");
     }
     ca.setDescription("description");
     ca.setAgreementUrl("agreement-url");
@@ -97,9 +99,10 @@
     ca.setExcludeProjectsRegexes(ImmutableList.of("ExcludedProject"));
 
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().replace(ca);
+      ContributorAgreement contributorAgreement = ca.build();
+      u.getConfig().replace(contributorAgreement);
       u.save();
-      return ca;
+      return contributorAgreement;
     }
   }
 
@@ -123,8 +126,11 @@
     if (isContributorAgreementsEnabled()) {
       assertThat(info.auth.useContributorAgreements).isTrue();
       assertThat(info.auth.contributorAgreements).hasSize(2);
-      assertAgreement(info.auth.contributorAgreements.get(0), caAutoVerify);
-      assertAgreement(info.auth.contributorAgreements.get(1), caNoAutoVerify);
+      // Sort to get a stable assertion as the API does not guarantee ordering.
+      List<AgreementInfo> agreements =
+          ImmutableList.sortedCopyOf(comparing(a -> a.name), info.auth.contributorAgreements);
+      assertAgreement(agreements.get(0), caAutoVerify);
+      assertAgreement(agreements.get(1), caNoAutoVerify);
     } else {
       assertThat(info.auth.useContributorAgreements).isNull();
       assertThat(info.auth.contributorAgreements).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
index 673379d..3c605e1 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -6,7 +6,6 @@
     group = "api_account",
     labels = [
         "api",
-        "noci",
         "no_windows",
     ],
     deps = [
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 746e6fe..f66bc8d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -29,6 +29,7 @@
 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.Theme;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -68,6 +69,7 @@
 
     // change all default values
     i.changesPerPage *= -1;
+    i.theme = Theme.DARK;
     i.dateFormat = DateFormat.US;
     i.timeFormat = TimeFormat.HHMM_24;
     i.emailStrategy = EmailStrategy.DISABLED;
@@ -90,6 +92,7 @@
     assertPrefs(o, i, "my");
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
+    assertThat(o.theme).isEqualTo(i.theme);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
new file mode 100644
index 0000000..0309646
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.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.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.time.Instant;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class MessageIdGeneratorIT extends AbstractDaemonTest {
+  @Inject private MessageIdGenerator messageIdGenerator;
+
+  @Test
+  public void fromAccountUpdate() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
+      String sha1 =
+          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
+
+  @Test
+  public void fromChangeUpdate() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      PushOneCommit.Result result = createChange();
+      PatchSet.Id patchsetId = result.getChange().currentPatchSet().id();
+      String messageId = messageIdGenerator.fromChangeUpdate(project, patchsetId).id();
+      String sha1 =
+          repo.getRefDatabase()
+              .findRef(String.format("%smeta", patchsetId.changeId().toRefPrefix()))
+              .getObjectId()
+              .getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
+
+  @Test
+  public void fromMailMessage() throws Exception {
+    String id = "unique-id";
+    MailMessage mailMessage =
+        MailMessage.builder()
+            .id(id)
+            .from(Address.create("email@email.com"))
+            .dateReceived(Instant.EPOCH)
+            .subject("subject")
+            .build();
+    assertThat(messageIdGenerator.fromMailMessage(mailMessage).id()).isEqualTo(id + "-REJECTION");
+  }
+
+  @Test
+  public void fromReasonAccountIdAndTimestamp() throws Exception {
+    String reason = "reason";
+    Instant timestamp = TimeUtil.now();
+    assertThat(
+            messageIdGenerator.fromReasonAccountIdAndTimestamp(reason, admin.id(), timestamp).id())
+        .isEqualTo(reason + "-" + admin.id().toString() + "-" + timestamp.toString());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index d04eebd..80431ee 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -31,7 +31,7 @@
 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.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/BUILD b/javatests/com/google/gerrit/acceptance/api/change/BUILD
index 9279488..94fb0dc 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/change/BUILD
@@ -1,11 +1,10 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_change",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = [
         "api",
-        "noci",
     ],
     deps = ["//java/com/google/gerrit/server/util/time"],
-)
+) for f in glob(["*IT.java"])]
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index d25e6541f..7c504b8 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,7 +47,6 @@
 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;
@@ -89,14 +88,14 @@
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.GlobalCapability;
-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.Account;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -120,7 +119,6 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -141,14 +139,10 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.MergeInput;
-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;
@@ -156,7 +150,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
@@ -183,7 +176,6 @@
 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;
@@ -1883,7 +1875,7 @@
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "ab" with one user: testUser
-    String email = "abcd@test.com";
+    String email = "abcd@example.com";
     String fullname = "abcd";
     Account.Id accountIdOfTestUser =
         accountOperations
@@ -1936,11 +1928,11 @@
     accountOperations
         .newAccount()
         .username("kobebryant")
-        .preferredEmail("kobebryant@test.com")
+        .preferredEmail("kobebryant@example.com")
         .fullname(testUserFullname)
         .create();
 
-    String myGroupUserEmail = "lee@test.com";
+    String myGroupUserEmail = "lee@example.com";
     String myGroupUserFullname = "lee";
     Account.Id accountIdOfGroupUser =
         accountOperations
@@ -2236,7 +2228,7 @@
     LabelType verified =
         label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
     projectOperations
@@ -2523,7 +2515,7 @@
         label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
     projectOperations
@@ -2626,7 +2618,7 @@
   }
 
   @Test
-  public void queryChangesNoLimit() throws Exception {
+  public void queryChangesNoLimitRegisteredUser() throws Exception {
     projectOperations
         .allProjectsForUpdate()
         .add(
@@ -2644,6 +2636,26 @@
   }
 
   @Test
+  public void queryChangesNoLimitIgnoredForAnonymousUser() throws Exception {
+    int limit = 2;
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.QUERY_LIMIT)
+                .group(SystemGroupBackend.ANONYMOUS_USERS)
+                .range(0, limit))
+        .update();
+    for (int i = 0; i < 3; i++) {
+      createChange();
+    }
+    requestScopeOperations.setApiUserAnonymous();
+    List<ChangeInfo> resultsWithDefaultLimit = gApi.changes().query().get();
+    List<ChangeInfo> resultsWithNoLimit = gApi.changes().query().withNoLimit().get();
+    assertThat(resultsWithDefaultLimit).hasSize(limit);
+    assertThat(resultsWithNoLimit).hasSize(limit);
+  }
+
+  @Test
   public void queryChangesStart() throws Exception {
     PushOneCommit.Result r1 = createChange();
     createChange();
@@ -2868,9 +2880,9 @@
     LabelType custom2 =
         label("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
-      u.getConfig().getLabelSections().put(custom1.getName(), custom1);
-      u.getConfig().getLabelSections().put(custom2.getName(), custom2);
+      u.getConfig().upsertLabelType(verified);
+      u.getConfig().upsertLabelType(custom1);
+      u.getConfig().upsertLabelType(custom2);
       u.save();
     }
     projectOperations
@@ -3070,7 +3082,8 @@
 
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.updated, serverIdent.get());
+          changeNoteUtil.newAccountIdIdent(
+              getAccount(admin.id()).id(), c.updated, serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -3079,7 +3092,8 @@
       RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
       expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
+          changeNoteUtil.newAccountIdIdent(
+              getAccount(admin.id()).id(), c.created, serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
@@ -3192,407 +3206,6 @@
   }
 
   @Test
-  public void createMergePatchSet() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster = pushTo("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, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    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.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
-  public void createMergePatchSet_Conflict() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
-  }
-
-  @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");
-
-    // create a change for master
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String parent = r.getCommit().getParent(0).getName();
-
-    // advance master branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2 inherit parent of ps1";
-    in.inheritParent = true;
-    gApi.changes().id(changeId).createMergePatchSet(in);
-    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);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isNotEqualTo(currentMaster.getCommit().getName());
-  }
-
-  @Test
-  public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("foo");
-    createBranch("bar");
-
-    // Create a merged commit on 'foo' branch.
-    merge(createChange("refs/for/foo"));
-
-    // Create the base change on 'bar' branch.
-    testRepo.reset(initialHead);
-    String baseChange = createChange("refs/for/bar").getChangeId();
-    gApi.changes().id(baseChange).setPrivate(true, "set private");
-
-    // Create the destination change on 'master' branch.
-    requestScopeOperations.setApiUser(user.id());
-    testRepo.reset(initialHead);
-    String changeId = createChange().getChangeId();
-
-    UnprocessableEntityException thrown =
-        assertThrows(
-            UnprocessableEntityException.class,
-            () ->
-                gApi.changes()
-                    .id(changeId)
-                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
-    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
-  }
-
-  @Test
-  public void createMergePatchSetBaseOnChange() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("foo");
-    createBranch("bar");
-
-    // Create a merged commit on 'foo' branch.
-    merge(createChange("refs/for/foo"));
-
-    // Create the base change on 'bar' branch.
-    testRepo.reset(initialHead);
-    PushOneCommit.Result result = createChange("refs/for/bar");
-    String baseChange = result.getChangeId();
-    String expectedParent = result.getCommit().getName();
-
-    // Create the destination change on 'master' branch.
-    testRepo.reset(initialHead);
-    String changeId = createChange().getChangeId();
-
-    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
-
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.subject).isEqualTo("create ps2");
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(expectedParent);
-  }
-
-  @Test
-  public void createMergePatchSetWithUnupportedMergeStrategy() 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.strategy = "unsupported-strategy";
-    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("invalid merge strategy: " + mergeInput.strategy);
-  }
-
-  private MergePatchSetInput createMergePatchSetInput(String baseChange) {
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "foo";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "create ps2";
-    in.inheritParent = false;
-    in.baseChange = baseChange;
-    return in;
-  }
-
-  @Test
   public void checkLabelsForUnsubmittedChange() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
@@ -3606,7 +3219,7 @@
     String heads = RefNames.REFS_HEADS + "*";
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
     projectOperations
@@ -3674,7 +3287,7 @@
 
     // add new label and assert that it's returned for existing changes
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
     projectOperations
@@ -3750,7 +3363,7 @@
             "Configure Notifications",
             "project.config",
             "[notify \"my=notify-config\"]\n"
-                + "  email = foo@test.com\n"
+                + "  email = foo@example.com\n"
                 + "  filter = dir:\\\"foo/bar/baz\\\"");
     push.to(RefNames.REFS_CONFIG);
     testRepo.reset(oldHead);
@@ -3762,7 +3375,8 @@
             admin.newIdent(), testRepo, "Test change", "foo/bar/baz/test.txt", "some content");
     PushOneCommit.Result r = push.to("refs/for/master");
     assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(Address.parse("foo@test.com"));
+    assertThat(sender.getMessages().get(0).rcpt())
+        .containsExactly(Address.parse("foo@example.com"));
 
     // Comment on the change.
     sender.clear();
@@ -3770,7 +3384,8 @@
     reviewInput.message = "some message";
     gApi.changes().id(r.getChangeId()).current().review(reviewInput);
     assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(Address.parse("foo@test.com"));
+    assertThat(sender.getMessages().get(0).rcpt())
+        .containsExactly(Address.parse("foo@example.com"));
   }
 
   @Test
@@ -4169,7 +3784,7 @@
         .startsWith(subject);
 
     List<CommentInfo> comments =
-        Iterables.getOnlyElement(gApi.changes().id(id).comments().values());
+        Iterables.getOnlyElement(gApi.changes().id(id).commentsRequest().get().values());
     assertThat(Iterables.getOnlyElement(comments).message).isEqualTo(ci.message);
   }
 
@@ -4377,6 +3992,7 @@
           .message("Modify rules.pl")
           .create();
     }
+    projectCache.evict(project);
   }
 
   @Test
@@ -4699,10 +4315,6 @@
     return pushTo("refs/for/master%wip");
   }
 
-  private BranchApi createBranch(String branch) throws Exception {
-    return createBranch(BranchNameKey.create(project, branch));
-  }
-
   private ThrowableSubject assertThatQueryException(String query) throws Exception {
     try {
       query(query);
@@ -4716,17 +4328,4 @@
   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 5523f9c..eb5b9b0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -19,8 +19,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
new file mode 100644
index 0000000..aee7f6f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -0,0 +1,641 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.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 com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+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.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CreateMergePatchSetIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Before
+  public void setUp() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  @Test
+  public void createMergePatchSet() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("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, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (ExtensionRegistry.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.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(BranchNameKey.create(project, "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
+  public void createMergePatchSet_Conflict() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
+  }
+
+  @Test
+  public void createMergePatchSet_ConflictAllowed() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "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 (ExtensionRegistry.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(BranchNameKey.create(project, "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(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String parent = r.getCommit().getParent(0).getName();
+
+    // advance master branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2 inherit parent of ps1";
+    in.inheritParent = true;
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    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);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isNotEqualTo(currentMaster.getCommit().getName());
+  }
+
+  @Test
+  public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "foo"));
+    createBranch(BranchNameKey.create(project, "bar"));
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    String baseChange = createChange("refs/for/bar").getChangeId();
+    gApi.changes().id(baseChange).setPrivate(true, "set private");
+
+    // Create the destination change on 'master' branch.
+    requestScopeOperations.setApiUser(user.id());
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
+    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
+  }
+
+  @Test
+  public void createMergePatchSetBaseOnChange() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "foo"));
+    createBranch(BranchNameKey.create(project, "bar"));
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    PushOneCommit.Result result = createChange("refs/for/bar");
+    String baseChange = result.getChangeId();
+    String expectedParent = result.getCommit().getName();
+
+    // Create the destination change on 'master' branch.
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.subject).isEqualTo("create ps2");
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(expectedParent);
+  }
+
+  @Test
+  public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "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.strategy = "unsupported-strategy";
+    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("invalid merge strategy: " + mergeInput.strategy);
+  }
+
+  @Test
+  public void createMergePatchSetWithOtherAuthor() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("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, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Other Author";
+    in.author.email = "otherauthor@example.com";
+    gApi.changes().id(changeId).createMergePatchSet(in);
+
+    // 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 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.");
+
+    CommitInfo commitInfo = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+    assertThat(commitInfo.message).contains(subject);
+    assertThat(commitInfo.author.name).isEqualTo("Other Author");
+    assertThat(commitInfo.author.email).isEqualTo("otherauthor@example.com");
+  }
+
+  @Test
+  public void createMergePatchSetWithSpecificAuthorButNoForgeAuthorPermission() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Foo";
+    in.author.email = "foo@example.com";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(
+            TestProjectUpdate.permissionKey(Permission.FORGE_AUTHOR)
+                .ref("refs/*")
+                .group(REGISTERED_USERS))
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    AuthException ex =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("not permitted: forge author on refs/heads/master");
+  }
+
+  @Test
+  public void createMergePatchSetWithMissingNameFailsWithBadRequestException() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Foo";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+  }
+
+  @Test
+  public void createMergePatchSetWithMissingEmailFailsWithBadRequestException() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.email = "Foo";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+  }
+
+  private MergePatchSetInput createMergePatchSetInput(String baseChange) {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "foo";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "create ps2";
+    in.inheritParent = false;
+    in.baseChange = baseChange;
+    return in;
+  }
+
+  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/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index d5089ff..31198d5 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.inject.AbstractModule;
+import java.util.Arrays;
 import org.junit.Test;
 
 @NoHttpd
@@ -50,6 +51,18 @@
   }
 
   @Test
+  public void querySingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()));
+  }
+
+  @Test
+  public void getSingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.toString()).get())));
+  }
+
+  @Test
   public void queryChangeWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
@@ -65,6 +78,53 @@
         (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
   }
 
+  @Test
+  public void queryChangeWithOptionBulkAttribute() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()),
+        (id, opts) ->
+            pluginInfosFromChangeInfos(
+                gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
+  }
+
+  @Test
+  public void getChangeWithOptionBulkAttribute() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())),
+        (id, opts) ->
+            pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get(opts))));
+  }
+
+  @Test
+  public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+    getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void getChangeWithPluginDefinedException() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeWithException(
+        id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())));
+  }
+
   static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
     @Override
     public void configure() {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
new file mode 100644
index 0000000..f4cf96d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.annotations.Exports;
+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.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.List;
+import org.junit.Test;
+
+public class PluginOperatorsIT extends AbstractDaemonTest {
+  @Inject private Provider<QueryChanges> queryChangesProvider;
+
+  @Test
+  public void getChangeWithIsOperator() throws Exception {
+    QueryChanges queryChanges = queryChangesProvider.get();
+    queryChanges.addQuery("is:changeNumberEven_myplugin");
+
+    String oddChangeId = createChange().getChangeId();
+    String evenChangeId = createChange().getChangeId();
+    assertThat(getChanges(queryChanges)).hasSize(0);
+
+    try (AutoCloseable ignored = installPlugin("myplugin", IsOperatorModule.class)) {
+      List<?> changes = getChanges(queryChanges);
+      assertThat(changes).hasSize(1);
+
+      ChangeInfo c = (ChangeInfo) changes.get(0);
+      String outputChangeId = c.changeId;
+      assertThat(outputChangeId).isEqualTo(evenChangeId);
+      assertThat(outputChangeId).isNotEqualTo(oddChangeId);
+    }
+
+    assertThat(getChanges(queryChanges)).hasSize(0);
+  }
+
+  protected static class IsOperatorModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(ChangeQueryBuilder.ChangeIsOperandFactory.class)
+          .annotatedWith(Exports.named("changeNumberEven"))
+          .to(SampleIsOperand.class);
+    }
+  }
+
+  private static class SampleIsOperand implements ChangeQueryBuilder.ChangeIsOperandFactory {
+    @Override
+    public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+      return new IsSamplePredicate();
+    }
+  }
+
+  private static class IsSamplePredicate extends OperatorPredicate<ChangeData>
+      implements Matchable<ChangeData> {
+
+    public IsSamplePredicate() {
+      super("is", "changeNumberEven");
+    }
+
+    @Override
+    public boolean match(ChangeData changeData) {
+      int id = changeData.getId().get();
+      return id % 2 == 0;
+    }
+
+    @Override
+    public int getCost() {
+      return 0;
+    }
+  }
+
+  private List<?> getChanges(QueryChanges queryChanges)
+      throws AuthException, PermissionBackendException, BadRequestException {
+    return queryChanges.apply(TopLevelResource.INSTANCE).value();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index be0cc04..7d73374 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -31,13 +31,16 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 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.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -54,6 +57,7 @@
 import java.sql.Timestamp;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -64,6 +68,7 @@
 
   @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final String COMMENT_TEXT = "The comment text";
   private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -382,6 +387,93 @@
         .contains("Exceeding maximum cumulative size of comments");
   }
 
+  @Test
+  public void ccToReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().reviewer(user.id().toString(), ReviewerState.CC, false);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.CC));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+
+    // CC -> Reviewer
+    ReviewInput input2 = new ReviewInput().reviewer(user.id().toString());
+    gApi.changes().id(r.getChangeId()).current().review(input2);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers2 =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers2).hasSize(1);
+    AccountInfo reviewer2 = Iterables.getOnlyElement(reviewers2.get(ReviewerState.REVIEWER));
+    assertThat(reviewer2._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void reviewerToCc() throws Exception {
+    // Admin owns the change
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().reviewer(user.id().toString());
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+
+    // Reviewer -> CC
+    ReviewInput input2 = new ReviewInput().reviewer(user.id().toString(), ReviewerState.CC, false);
+    gApi.changes().id(r.getChangeId()).current().review(input2);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers2 =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers2).hasSize(1);
+    AccountInfo reviewer2 = Iterables.getOnlyElement(reviewers2.get(ReviewerState.CC));
+    assertThat(reviewer2._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void votingMakesCallerReviewer() throws Exception {
+    // Admin owns the change
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().label("Code-Review", 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void commentingMakesUserCC() throws Exception {
+    // Admin owns the change
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().message("Foo bar!");
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.CC));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+  }
+
   private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException {
     return gApi.changes().id(changeId).robotComments().values().stream()
         .flatMap(Collection::stream)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index f043c9b..97b7148 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 892455b..14704ad 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -31,9 +31,10 @@
 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.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -159,16 +160,16 @@
 
     // Create hidden project.
     Project.NameKey hiddenProject = projectOperations.newProject().create();
+    TestRepository<InMemoryRepository> hiddenRepo = cloneProject(hiddenProject, admin);
+    // Create 2 hidden changes.
+    createChange(hiddenRepo);
+    createChange(hiddenRepo);
+    // Actually hide project
     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:
@@ -364,12 +365,16 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    cfg.getAccessSections().stream()
-        .filter(
-            s ->
-                s.getName().startsWith("refs/heads/")
-                    || s.getName().startsWith("refs/for/")
-                    || s.getName().equals("refs/*"))
-        .forEach(s -> Arrays.stream(permissions).forEach(s::removePermission));
+    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+      if (s.getName().startsWith("refs/heads/")
+          || s.getName().startsWith("refs/for/")
+          || s.getName().equals("refs/*")) {
+        cfg.upsertAccessSection(
+            s.getName(),
+            updatedSection -> {
+              Arrays.stream(permissions).forEach(p -> updatedSection.remove(Permission.builder(p)));
+            });
+      }
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 24d08db..a3a089f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -26,8 +26,8 @@
 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.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -250,6 +250,17 @@
   }
 
   @Test
+  public void revertChangeWithWip() 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();
+
+    RevertInput in = createWipRevertInput();
+    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert(in).get();
+    assertThat(revertChange.workInProgress).isTrue();
+  }
+
+  @Test
   public void revertWithDefaultTopic() throws Exception {
     PushOneCommit.Result result = createChange();
     gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
@@ -321,6 +332,18 @@
   }
 
   @Test
+  public void revertNotificationsSupressedOnWip() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revert(createWipRevertInput()).get();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void suppressRevertNotifications() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
@@ -398,7 +421,7 @@
         .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.getConfig().updateProject(p -> p.setState(ProjectState.READ_ONLY));
       u.save();
     }
 
@@ -481,7 +504,7 @@
 
     // revoke write permissions for the first repository.
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getProject().setState(ProjectState.READ_ONLY);
+      u.getConfig().updateProject(p -> p.setState(ProjectState.READ_ONLY));
       u.save();
     }
 
@@ -659,6 +682,49 @@
   }
 
   @Test
+  public void revertSubmissionWipNotificationsAreSupressed() throws Exception {
+    String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
+    approve(changeId1);
+    gApi.changes().id(changeId1).addReviewer(user.email());
+    String changeId2 = createChange("second change", "b.txt", "other").getChangeId();
+    approve(changeId2);
+    gApi.changes().id(changeId2).addReviewer(user.email());
+
+    gApi.changes().id(changeId2).current().submit();
+
+    sender.clear();
+
+    RevertInput revertInput = createWipRevertInput();
+    // Setting the Notifications to ALL will be overridden because the WIP flag overrides the
+    // notifications to OWNER
+    revertInput.notify = NotifyHandling.ALL;
+    gApi.changes().id(changeId2).revertSubmission(revertInput);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void revertSubmissionWipMarksAllChangesAsWip() throws Exception {
+    String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
+    approve(changeId1);
+    gApi.changes().id(changeId1).addReviewer(user.email());
+    String changeId2 = createChange("second change", "b.txt", "other").getChangeId();
+    approve(changeId2);
+    gApi.changes().id(changeId2).addReviewer(user.email());
+
+    gApi.changes().id(changeId2).current().submit();
+
+    sender.clear();
+
+    RevertInput revertInput = createWipRevertInput();
+    RevertSubmissionInfo revertSubmissionInfo =
+        gApi.changes().id(changeId2).revertSubmission(revertInput);
+
+    assertThat(revertSubmissionInfo.revertChanges.stream().allMatch(r -> r.workInProgress))
+        .isTrue();
+  }
+
+  @Test
   public void revertSubmissionIdenticalTreeIsAllowed() throws Exception {
     String unrelatedChange = createChange("change1", "a.txt", "message").getChangeId();
     approve(unrelatedChange);
@@ -1283,6 +1349,7 @@
           .message("Modify rules.pl")
           .create();
     }
+    projectCache.evict(project);
   }
 
   private List<ChangeApi> getChangeApis(RevertSubmissionInfo revertSubmissionInfo)
@@ -1293,4 +1360,10 @@
     }
     return results;
   }
+
+  private RevertInput createWipRevertInput() {
+    RevertInput input = new RevertInput();
+    input.workInProgress = true;
+    return input;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 923b66f..58ea6ea 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -26,7 +26,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
@@ -39,7 +39,7 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -78,8 +78,8 @@
     try (ProjectConfigUpdate u = updateProject(project)) {
       // Overwrite "Code-Review" label that is inherited from All-Projects.
       // This way changes to the "Code Review" label don't affect other tests.
-      LabelType codeReview =
-          label(
+      LabelType.Builder codeReview =
+          labelBuilder(
               "Code-Review",
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
@@ -87,12 +87,12 @@
               value(-1, "I would prefer that you didn't submit this"),
               value(-2, "Do not submit"));
       codeReview.setCopyAllScoresIfNoChange(false);
-      u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
+      u.getConfig().upsertLabelType(codeReview.build());
 
-      LabelType verified =
-          label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      LabelType.Builder verified =
+          labelBuilder("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       verified.setCopyAllScoresIfNoChange(false);
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified.build());
 
       u.save();
     }
@@ -121,7 +121,7 @@
   @Test
   public void stickyOnAnyScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyAnyScore(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAnyScore(true));
       u.save();
     }
 
@@ -143,7 +143,7 @@
   @Test
   public void stickyOnMinScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMinScore(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -165,7 +165,7 @@
   @Test
   public void stickyOnMaxScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
       u.save();
     }
 
@@ -190,9 +190,8 @@
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getLabelSections()
-          .get("Code-Review")
-          .setCopyValues(ImmutableList.of((short) -1, (short) 1));
+          .updateLabelType(
+              "Code-Review", b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
       u.save();
     }
 
@@ -216,7 +215,7 @@
   @Test
   public void stickyOnTrivialRebase() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
       u.save();
     }
 
@@ -262,7 +261,7 @@
   @Test
   public void stickyOnNoCodeChange() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -287,9 +286,7 @@
   public void stickyOnMergeFirstParentUpdate() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getLabelSections()
-          .get("Code-Review")
-          .setCopyAllScoresOnMergeFirstParentUpdate(true);
+          .updateLabelType("Code-Review", b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
       u.save();
     }
 
@@ -313,7 +310,7 @@
   @Test
   public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresIfNoChange(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresIfNoChange(true));
       u.save();
     }
 
@@ -330,8 +327,8 @@
   @Test
   public void removedVotesNotSticky() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
-      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
+      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -360,8 +357,8 @@
   @Test
   public void stickyAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
-      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -386,8 +383,8 @@
     // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
     // work in O(num-patch-sets). This test ensures that we aren't regressing.
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
-      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -418,8 +415,8 @@
   @Test
   public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMinScore(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -459,7 +456,7 @@
   public void deleteStickyVote() throws Exception {
     String label = "Code-Review";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get(label).setCopyMaxScore(true);
+      u.getConfig().updateLabelType(label, b -> b.setCopyMaxScore(true));
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index dab2d00..a9afcbc 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -106,6 +106,7 @@
       r.rule = rule;
       r.commit(md);
     }
+    projectCache.evict(project);
   }
 
   private static final String SUBMIT_TYPE_FROM_SUBJECT =
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 10bd03b..8dbec28 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -56,10 +56,10 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 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.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -100,6 +100,7 @@
 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.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -111,7 +112,6 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -916,7 +916,7 @@
   @Test
   public void defaultGroupsCreated() throws Exception {
     Iterable<String> names = gApi.groups().list().getAsMap().keySet();
-    assertThat(names).containsAtLeast("Administrators", "Non-Interactive Users").inOrder();
+    assertThat(names).containsAtLeast("Administrators", "Service Users").inOrder();
   }
 
   @Test
@@ -1527,12 +1527,8 @@
   }
 
   private static Correspondence<AccountInfo, String> getAccountToUsernameCorrespondence() {
-    return Correspondence.from(
-        (actualAccount, expectedName) -> {
-          String username = actualAccount == null ? null : actualAccount.username;
-          return Objects.equals(username, expectedName);
-        },
-        "has username");
+    return NullAwareCorrespondence.transforming(
+        accountInfo -> accountInfo.username, "has username");
   }
 
   private void assertStaleGroupAndReindex(AccountGroup.UUID groupUuid) throws IOException {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index 6fcca8c..c977d43 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -18,9 +18,9 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.ServerInitiated;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 3fc6e44..f1d537f 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -27,8 +27,8 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 5ee9340..3b5ac78 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -24,11 +24,12 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
@@ -147,77 +148,237 @@
   }
 
   @Test
-  public void cherryPickWithoutMessage() throws Exception {
-    String branch = "foo";
+  public void cherryPickWithoutMessageSameBranch() throws Exception {
+    String destBranch = "master";
 
     // Create change to cherry-pick
-    RevCommit revCommit = createChange().getCommit();
-
-    // Create target branch to cherry-pick to.
-    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());
+    PushOneCommit.Result r = createChange();
+    ChangeInfo changeToCherryPick = info(r.getChangeId());
+    RevCommit commitToCherryPick = r.getCommit();
 
     // Cherry-pick without message.
     CherryPickInput input = new CherryPickInput();
-    input.destination = branch;
-    String changeId =
-        gApi.projects().name(project.get()).commit(revCommit.name()).cherryPick(input).get().id;
+    input.destination = destBranch;
+    ChangeInfo cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.name())
+            .cherryPick(input)
+            .get();
 
+    // Expect that the Change-Id of the cherry-picked commit was used for the cherry-pick change.
+    // New patch-set to existing change was uploaded.
+    assertThat(cherryPickResult._number).isEqualTo(changeToCherryPick._number);
+    assertThat(cherryPickResult.revisions).hasSize(2);
+    assertThat(cherryPickResult.changeId).isEqualTo(changeToCherryPick.changeId);
+    assertThat(cherryPickResult.messages).hasSize(2);
+
+    // Cherry-pick of is not set, because the source change was not provided.
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
     // Expect that the message of the cherry-picked commit was used for the cherry-pick change.
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
-    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
     assertThat(revInfo).isNotNull();
-    assertThat(revInfo.commit.message).isEqualTo(revCommit.getFullMessage());
+    assertThat(revInfo.commit.message).isEqualTo(commitToCherryPick.getFullMessage());
   }
 
   @Test
-  public void cherryPickCommitWithoutChangeId() throws Exception {
+  public void cherryPickWithoutMessageOtherBranch() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    // Create change to cherry-pick
+    PushOneCommit.Result r = createChange();
+    ChangeInfo changeToCherryPick = info(r.getChangeId());
+    RevCommit commitToCherryPick = r.getCommit();
+
+    // Cherry-pick without message.
     CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
+    input.destination = destBranch;
+    ChangeInfo cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.name())
+            .cherryPick(input)
+            .get();
+
+    // Expect that the Change-Id of the cherry-picked commit was used for the cherry-pick change.
+    // New change in destination branch was created.
+    assertThat(cherryPickResult._number).isGreaterThan(changeToCherryPick._number);
+    assertThat(cherryPickResult.revisions).hasSize(1);
+    assertThat(cherryPickResult.changeId).isEqualTo(changeToCherryPick.changeId);
+    assertThat(cherryPickResult.messages).hasSize(1);
+
+    // Cherry-pick of is not set, because the source change was not provided.
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
+    // Expect that the message of the cherry-picked commit was used for the cherry-pick change.
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(commitToCherryPick.getFullMessage());
+  }
+
+  @Test
+  public void cherryPickCommitWithoutChangeIdCreateNewChange() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
     input.message = "it goes to foo branch";
-    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
 
-    RevCommit revCommit = createNewCommitWithoutChangeId("refs/heads/master", "a.txt", "content");
-    ChangeInfo changeInfo =
-        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+    RevCommit commitToCherryPick =
+        createNewCommitWithoutChangeId("refs/heads/master", "a.txt", "content");
+    ChangeInfo cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.getName())
+            .cherryPick(input)
+            .get();
 
-    assertThat(changeInfo.messages).hasSize(1);
-    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    assertThat(cherryPickResult.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
     String expectedMessage =
-        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+        String.format("Patch Set 1: Cherry Picked from commit %s.", commitToCherryPick.getName());
     assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
 
-    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
     assertThat(revInfo).isNotNull();
     CommitInfo commitInfo = revInfo.commit;
     assertThat(commitInfo.message)
-        .isEqualTo(input.message + "\n\nChange-Id: " + changeInfo.changeId + "\n");
+        .isEqualTo(input.message + "\n\nChange-Id: " + cherryPickResult.changeId + "\n");
   }
 
   @Test
-  public void cherryPickCommitWithChangeId() throws Exception {
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
+  public void cherryPickCommitWithChangeIdCreateNewChange() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
 
-    RevCommit revCommit = createChange().getCommit();
-    List<String> footers = revCommit.getFooterLines("Change-Id");
+    PushOneCommit.Result r = createChange();
+    ChangeInfo changeToCherryPick = info(r.getChangeId());
+    RevCommit commitToCherryPick = r.getCommit();
+    List<String> footers = commitToCherryPick.getFooterLines("Change-Id");
     assertThat(footers).hasSize(1);
     String changeId = footers.get(0);
 
-    input.message = "it goes to foo branch\n\nChange-Id: " + changeId;
-    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message =
+        String.format(
+            "it goes to foo branch\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n\nChange-Id: %s\n",
+            changeId);
 
-    ChangeInfo changeInfo =
-        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+    ChangeInfo cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.getName())
+            .cherryPick(input)
+            .get();
 
-    assertThat(changeInfo.messages).hasSize(1);
-    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    // No change was found in destination branch with the provided Change-Id.
+    assertThat(cherryPickResult._number).isGreaterThan(changeToCherryPick._number);
+    assertThat(cherryPickResult.changeId).isEqualTo(changeId);
+    assertThat(cherryPickResult.revisions).hasSize(1);
+    assertThat(cherryPickResult.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
     String expectedMessage =
-        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+        String.format("Patch Set 1: Cherry Picked from commit %s.", commitToCherryPick.getName());
     assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
 
-    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    // Cherry-pick of is not set, because the source change was not provided.
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
     assertThat(revInfo).isNotNull();
-    assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
+    assertThat(revInfo.commit.message).isEqualTo(input.message);
+  }
+
+  @Test
+  public void cherryPickCommitToExistingChange() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch);
+    ChangeInfo existingDestChange = info(r.getChangeId());
+
+    String commitToCherryPick = createChange().getCommit().getName();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message =
+        String.format(
+            "it goes to foo branch\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n\nChange-Id: %s\n",
+            existingDestChange.changeId);
+    input.allowConflicts = true;
+    input.allowEmpty = true;
+
+    ChangeInfo cherryPickResult =
+        gApi.projects().name(project.get()).commit(commitToCherryPick).cherryPick(input).get();
+
+    // New patch-set to existing change was uploaded.
+    assertThat(cherryPickResult._number).isEqualTo(existingDestChange._number);
+    assertThat(cherryPickResult.changeId).isEqualTo(existingDestChange.changeId);
+    assertThat(cherryPickResult.messages).hasSize(2);
+    assertThat(cherryPickResult.revisions).hasSize(2);
+    Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
+
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 1.");
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 2.");
+    // Cherry-pick of is not set, because the source change was not provided.
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(input.message);
+  }
+
+  @Test
+  public void cherryPickCommitToExistingCherryPickedChange() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch);
+    ChangeInfo existingDestChange = info(r.getChangeId());
+
+    r = createChange();
+    ChangeInfo changeToCherryPick = info(r.getChangeId());
+    RevCommit commitToCherryPick = r.getCommit();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message =
+        String.format("it goes to foo branch\n\nChange-Id: %s\n", existingDestChange.changeId);
+    input.allowConflicts = true;
+    input.allowEmpty = true;
+    // Use RevisionAPI to submit initial cherryPick.
+    ChangeInfo cherryPickResult =
+        gApi.changes().id(changeToCherryPick.changeId).current().cherryPick(input).get();
+    assertThat(cherryPickResult.changeId).isEqualTo(existingDestChange.changeId);
+    // Cherry-pick was set.
+    assertThat(cherryPickResult.cherryPickOfChange).isEqualTo(changeToCherryPick._number);
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isEqualTo(1);
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(input.message);
+    // Use CommitApi to update the cherryPick change.
+    cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.getName())
+            .cherryPick(input)
+            .get();
+
+    assertThat(cherryPickResult.changeId).isEqualTo(existingDestChange.changeId);
+    assertThat(cherryPickResult.messages).hasSize(3);
+    Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
+
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 1.");
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 2.");
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 3.");
+    // Cherry-pick was reset to empty value.
+    assertThat(cherryPickResult._number).isEqualTo(existingDestChange._number);
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index 6442645..a22b558 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 8dc76dd..2847f64 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_GLOBAL;
@@ -39,8 +40,10 @@
 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.AccountGroup;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -55,6 +58,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
@@ -63,14 +67,15 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.CommentLinkInfoImpl;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -697,6 +702,31 @@
   }
 
   @Test
+  public void pluginConfigsReturnedWhenRefsMetaConfigReadable() throws Exception {
+    ProjectConfigEntry entry = new ProjectConfigEntry("enabled", "true");
+    try (Registration ignored =
+        extensionRegistry.newRegistration().add(entry, "test-config-entry")) {
+      // The admin can see refs/meta/config and hence has the READ_CONFIG permission.
+      requestScopeOperations.setApiUser(admin.id());
+      ConfigInfo configInfo = getConfig();
+      assertThat(configInfo.pluginConfig).isNotNull();
+      assertThat(configInfo.pluginConfig).isNotEmpty();
+    }
+  }
+
+  @Test
+  public void pluginConfigsNotReturnedWhenRefsMetaConfigNotReadable() throws Exception {
+    ProjectConfigEntry entry = new ProjectConfigEntry("enabled", "true");
+    try (Registration ignored =
+        extensionRegistry.newRegistration().add(entry, "test-config-entry")) {
+      // This user cannot see refs/meta/config and hence does not have the READ_CONFIG permission.
+      requestScopeOperations.setApiUser(user.id());
+      ConfigInfo configInfo = getConfig();
+      assertThat(configInfo.pluginConfig).isNull();
+    }
+  }
+
+  @Test
   public void noCommentlinksByDefault() throws Exception {
     assertThat(getConfig().commentlinks).isEmpty();
   }
@@ -915,8 +945,48 @@
                 projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).name()));
   }
 
+  @Test
+  public void renamingGroupGetsPersisted() throws Exception {
+    String name = name("Name1");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    // Use group in a permission
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(AccountGroup.uuid(group.id)))
+        .update();
+    Optional<String> beforeRename =
+        projectCache.get(project).get().getLocalGroups().stream()
+            .filter(g -> g.getUUID().get().equals(group.id))
+            .map(GroupReference::getName)
+            .findAny();
+    // Groups created with ProjectOperations always have their UUID as local name
+    assertThat(beforeRename).hasValue(group.id);
+
+    // Rename the group directly on the project config
+    String newName = name("Name2");
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+      config.renameGroup(AccountGroup.uuid(group.id), newName);
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
+    }
+
+    Optional<String> afterRename =
+        projectCache.get(project).get().getLocalGroups().stream()
+            .filter(g -> g.getUUID().get().equals(group.id))
+            .map(GroupReference::getName)
+            .findAny();
+    assertThat(afterRename).hasValue(newName);
+  }
+
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
-    return new CommentLinkInfoImpl(name, match, link, null /*html*/, null /*enabled*/);
+    CommentLinkInfo info = new CommentLinkInfo();
+    info.name = name;
+    info.match = match;
+    info.link = link;
+    return info;
   }
 
   private void assertCommentLinks(ConfigInfo actual, Map<String, CommentLinkInfo> expected) {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index dad09f9..e45d95c 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -104,18 +104,18 @@
     Project.NameKey p1 = projectOperations.newProject().create();
     Project.NameKey p2 = projectOperations.newProject().create();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getProject().setParentName(p1);
+      u.getConfig().updateProject(p -> p.setParent(p1));
       u.save();
     }
     assertThat(stalenessChecker.check(project).isStale()).isFalse();
 
-    updateProjectConfigWithoutIndexUpdate(p1, c -> c.getProject().setParentName(p2));
+    updateProjectConfigWithoutIndexUpdate(p1, c -> c.updateProject(p -> p.setParent(p2)));
     assertThat(stalenessChecker.check(project).isStale()).isTrue();
   }
 
   private void updateProjectConfigWithoutIndexUpdate(Project.NameKey project) throws Exception {
     updateProjectConfigWithoutIndexUpdate(
-        project, c -> c.getProject().setDescription("making it stale"));
+        project, c -> c.updateProject(p -> p.setDescription("making it stale")));
   }
 
   private void updateProjectConfigWithoutIndexUpdate(
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 1539334..2bdbe50 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -23,7 +23,7 @@
 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.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
index 06e45c5..3bfe2f0 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -1,7 +1,7 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_revision",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = ["api"],
-)
+) for f in glob(["*IT.java"])]
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/GetBlameIT.java b/javatests/com/google/gerrit/acceptance/api/revision/GetBlameIT.java
index 8dfebad..62140ed 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/GetBlameIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/GetBlameIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.revision;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -35,6 +36,16 @@
   }
 
   @Test
+  public void forPatchsetLevelFile() throws Exception {
+    PushOneCommit.Result r = createChange("Test Change", "foo.txt", "FOO");
+    List<BlameInfo> blameInfos =
+        gApi.changes().id(r.getChangeId()).current().file(PATCHSET_LEVEL).blameRequest().get();
+
+    // File doesn't exist in commit.
+    assertThat(blameInfos).isEmpty();
+  }
+
+  @Test
   public void forNonExistingFileFromBase() throws Exception {
     PushOneCommit.Result r = createChange("Test Change", "foo.txt", "FOO");
     List<BlameInfo> blameInfos =
@@ -51,6 +62,22 @@
   }
 
   @Test
+  public void forPatchsetLevelFileFromBase() throws Exception {
+    PushOneCommit.Result r = createChange("Test Change", "foo.txt", "FOO");
+    List<BlameInfo> blameInfos =
+        gApi.changes()
+            .id(r.getChangeId())
+            .current()
+            .file(PATCHSET_LEVEL)
+            .blameRequest()
+            .forBase(true)
+            .get();
+
+    // File doesn't exist in base commit.
+    assertThat(blameInfos).isEmpty();
+  }
+
+  @Test
   public void forNewlyAddedFile() throws Exception {
     PushOneCommit.Result r = createChange("Test Change", "foo.txt", "FOO");
     List<BlameInfo> blameInfos =
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
new file mode 100644
index 0000000..d3d8457
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -0,0 +1,1891 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.revision;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.collect.MoreCollectors.onlyElement;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.TestCommentCreation;
+import com.google.gerrit.acceptance.testsuite.change.TestPatchset;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.inject.Inject;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.junit.Ignore;
+import org.junit.Test;
+
+public class PortedCommentsIT extends AbstractDaemonTest {
+
+  @Inject private ChangeOperations changeOps;
+  @Inject private AccountOperations accountOps;
+  @Inject private RequestScopeOperations requestScopeOps;
+
+  @Test
+  public void onlyCommentsBeforeTargetPatchsetArePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String comment1Uuid = newComment(patchset1Id).create();
+    newComment(patchset2Id).create();
+    newComment(patchset3Id).create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThatList(portedComments).comparingElementsUsing(hasUuid()).containsExactly(comment1Uuid);
+  }
+
+  @Test
+  public void commentsOnAnyPatchsetBeforeTargetPatchsetArePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    changeOps.change(changeId).newPatchset().create();
+    PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
+    PatchSet.Id patchset4Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String comment1Uuid = newComment(patchset1Id).create();
+    String comment3Uuid = newComment(patchset3Id).create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset4Id));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(comment1Uuid, comment3Uuid);
+  }
+
+  @Test
+  public void severalCommentsFromEarlierPatchsetArePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String comment1Uuid = newComment(patchset1Id).create();
+    String comment2Uuid = newComment(patchset1Id).create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(comment1Uuid, comment2Uuid);
+  }
+
+  @Test
+  public void completeCommentThreadIsPorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String rootCommentUuid = newComment(patchset1Id).create();
+    String child1CommentUuid = newComment(patchset1Id).parentUuid(rootCommentUuid).create();
+    String child2CommentUuid = newComment(patchset1Id).parentUuid(child1CommentUuid).create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(rootCommentUuid, child1CommentUuid, child2CommentUuid);
+  }
+
+  @Test
+  public void onlyUnresolvedPublishedCommentsArePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    newComment(patchset1Id).resolved().create();
+    String comment2Uuid = newComment(patchset1Id).unresolved().create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(comment2Uuid);
+  }
+
+  @Test
+  public void resolvedAndUnresolvedDraftCommentsArePorted() throws Exception {
+    Account.Id accountId = accountOps.newAccount().create();
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String comment1Uuid = newDraftComment(patchset1Id).author(accountId).resolved().create();
+    String comment2Uuid = newDraftComment(patchset1Id).author(accountId).unresolved().create();
+
+    List<CommentInfo> portedComments =
+        flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(comment1Uuid, comment2Uuid);
+  }
+
+  @Test
+  public void unresolvedStateOfLastCommentInThreadMatters() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String rootComment1Uuid = newComment(patchset1Id).resolved().create();
+    String childComment1Uuid =
+        newComment(patchset1Id).parentUuid(rootComment1Uuid).unresolved().create();
+    String rootComment2Uuid = newComment(patchset1Id).unresolved().create();
+    newComment(patchset1Id).parentUuid(rootComment2Uuid).resolved().create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(rootComment1Uuid, childComment1Uuid);
+  }
+
+  @Test
+  public void unresolvedStateOfLastCommentByDateMattersForBranchedThreads() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments. Comments should be more than 1 second apart as NoteDb only supports second
+    // precision.
+    LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+    String rootCommentUuid = newComment(patchset1Id).resolved().createdOn(now).create();
+    String childComment1Uuid =
+        newComment(patchset1Id)
+            .parentUuid(rootCommentUuid)
+            .resolved()
+            .createdOn(now.plusSeconds(5))
+            .create();
+    String childComment2Uuid =
+        newComment(patchset1Id)
+            .parentUuid(rootCommentUuid)
+            .unresolved()
+            .createdOn(now.plusSeconds(10))
+            .create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(rootCommentUuid, childComment1Uuid, childComment2Uuid);
+  }
+
+  @Test
+  public void unresolvedStateOfDraftCommentsIsIgnoredForPublishedComments() throws Exception {
+    Account.Id accountId = accountOps.newAccount().create();
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String rootComment1Uuid = newComment(patchset1Id).resolved().create();
+    newDraftComment(patchset1Id)
+        .author(accountId)
+        .parentUuid(rootComment1Uuid)
+        .unresolved()
+        .create();
+    String rootComment2Uuid = newComment(patchset1Id).unresolved().create();
+    newDraftComment(patchset1Id).author(accountId).parentUuid(rootComment2Uuid).resolved().create();
+
+    // Draft comments are only visible to their author.
+    requestScopeOps.setApiUser(accountId);
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(rootComment2Uuid);
+  }
+
+  @Test
+  public void draftCommentsAreNotPortedViaApiForPublishedComments() throws Exception {
+    Account.Id accountId = accountOps.newAccount().create();
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add draft comment.
+    newDraftComment(patchset1Id).author(accountId).create();
+
+    // Draft comments are only visible to their author.
+    requestScopeOps.setApiUser(accountId);
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThatList(portedComments).isEmpty();
+  }
+
+  @Test
+  public void publishedCommentsAreNotPortedViaApiForDraftComments() throws Exception {
+    Account.Id accountId = accountOps.newAccount().create();
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    newComment(patchset1Id).author(accountId).create();
+
+    List<CommentInfo> portedComments =
+        flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
+
+    assertThatList(portedComments).isEmpty();
+  }
+
+  @Test
+  public void draftCommentCanBePorted() throws Exception {
+    Account.Id accountId = accountOps.newAccount().create();
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add draft comment.
+    newComment(patchset1Id).author(accountId).create();
+
+    List<CommentInfo> portedComments =
+        flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
+
+    assertThatList(portedComments).isEmpty();
+  }
+
+  @Test
+  public void portedDraftCommentOfOtherUserIsNotVisible() throws Exception {
+    Account.Id userId = accountOps.newAccount().create();
+    Account.Id otherUserId = accountOps.newAccount().create();
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add draft comment.
+    newComment(patchset1Id).author(otherUserId).create();
+
+    List<CommentInfo> portedComments = flatten(getPortedDraftCommentsOfUser(patchset2Id, userId));
+
+    assertThatList(portedComments).isEmpty();
+  }
+
+  @Test
+  public void publishedCommentsOfAllTypesArePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String rangeCommentUuid =
+        newComment(patchset1Id)
+            .message("Range comment")
+            .fromLine(1)
+            .charOffset(2)
+            .toLine(2)
+            .charOffset(1)
+            .ofFile("myFile")
+            .create();
+    String lineCommentUuid =
+        newComment(patchset1Id).message("Line comment").onLine(1).ofFile("myFile").create();
+    String fileCommentUuid =
+        newComment(patchset1Id).message("File comment").onFileLevelOf("myFile").create();
+    String patchsetLevelCommentUuid =
+        newComment(patchset1Id).message("Patchset-level comment").onPatchsetLevel().create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(
+            rangeCommentUuid, lineCommentUuid, fileCommentUuid, patchsetLevelCommentUuid);
+  }
+
+  @Test
+  public void commentOnParentCommitIsPorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String commentUuid = newComment(patchset1Id).onParentCommit().create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+  }
+
+  @Test
+  public void commentOnInvalidParentIsPorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String commentUuid = newComment(patchset1Id).onSecondParentCommit().create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+  }
+
+  @Test
+  public void commentsOnInvalidPositionArePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String commentUuid1 = newComment(patchset1Id).onFileLevelOf("not-existing file").create();
+    String commentUuid2 = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(commentUuid1, commentUuid2);
+  }
+
+  @Test
+  public void commentsOnInvalidPositionKeepTheirInvalidPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    newComment(patchset1Id).onFileLevelOf("not-existing file").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("not-existing file");
+  }
+
+  @Test
+  public void portedCommentHasOriginalUuid() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThatList(portedComments).onlyElement().uuid().isEqualTo(commentUuid);
+  }
+
+  @Test
+  public void portedCommentHasOriginalPatchset() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).patchSet().isEqualTo(patchset1Id.get());
+  }
+
+  @Test
+  public void portedDraftCommentHasPatchsetFilled() throws Exception {
+    // Set up change and patchsets.
+    Account.Id authorId = accountOps.newAccount().create();
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newDraftComment(patchset1Id).author(authorId).create();
+
+    Map<String, List<CommentInfo>> portedComments =
+        getPortedDraftCommentsOfUser(patchset2Id, authorId);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+
+    // We explicitly need to request that the patchset field is filled, which we could have missed
+    // for drafts. -> Test that aspect. Don't verify the actual patchset number as that's already
+    // covered by the previous test.
+    assertThat(portedComment).patchSet().isNotNull();
+  }
+
+  @Test
+  public void portedCommentHasOriginalPatchsetCommitId() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1.patchsetId()).create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).commitId().isEqualTo(patchset1.commitId().name());
+  }
+
+  @Test
+  public void portedCommentHasOriginalMessage() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1.patchsetId()).message("My comment text").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).message().isEqualTo("My comment text");
+  }
+
+  @Test
+  public void portedReplyStillRefersToParentComment() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String rootCommentUuid = newComment(patchset1.patchsetId()).create();
+    String childCommentUuid =
+        newComment(patchset1.patchsetId()).parentUuid(rootCommentUuid).create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, childCommentUuid);
+
+    assertThat(portedComment).inReplyTo().isEqualTo(rootCommentUuid);
+  }
+
+  @Test
+  public void portedPublishedCommentHasOriginalAuthor() throws Exception {
+    // Set up change and patchsets.
+    Account.Id authorId = accountOps.newAccount().create();
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).author(authorId).create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).author().id().isEqualTo(authorId.get());
+  }
+
+  @Test
+  public void portedDraftCommentHasNoAuthor() throws Exception {
+    // Set up change and patchsets.
+    Account.Id authorId = accountOps.newAccount().create();
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newDraftComment(patchset1Id).author(authorId).create();
+
+    Map<String, List<CommentInfo>> portedComments =
+        getPortedDraftCommentsOfUser(patchset2Id, authorId);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+
+    // Authors of draft comments are never set.
+    assertThat(portedComment).author().isNull();
+  }
+
+  @Test
+  public void portedCommentHasOriginalTag() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1.patchsetId()).tag("My comment tag").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).tag().isEqualTo("My comment tag");
+  }
+
+  @Test
+  public void portedCommentHasUpdatedTimestamp() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).updated().isNotNull();
+  }
+
+  @Test
+  public void portedCommentDoesNotHaveChangeMessageId() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    // There's currently no use case for linking ported comments to specific change messages. Hence,
+    // there's no reason to fill this field, which requires additional computations.
+    // Besides, we also don't fill this field for the comments requested for a specific patchset.
+    assertThat(portedComment).changeMessageId().isNull();
+  }
+
+  @Test
+  public void pathOfPortedCommentIsOnlyIndicatedInMap() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("myFile");
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).path().isNull();
+  }
+
+  @Test
+  public void portedRangeCommentCanHandleAddedLines() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(3)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).range().startLine().isEqualTo(5);
+    assertThat(portedComment).range().startCharacter().isEqualTo(2);
+    assertThat(portedComment).range().endLine().isEqualTo(6);
+    assertThat(portedComment).range().endCharacter().isEqualTo(5);
+  }
+
+  @Test
+  public void portedRangeCommentCanHandleDeletedLines() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(3)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).range().startLine().isEqualTo(2);
+    assertThat(portedComment).range().startCharacter().isEqualTo(2);
+    assertThat(portedComment).range().endLine().isEqualTo(3);
+    assertThat(portedComment).range().endCharacter().isEqualTo(5);
+  }
+
+  @Test
+  public void portedRangeCommentCanHandlePureRename() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(3)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("newFileName");
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).range().startLine().isEqualTo(3);
+    assertThat(portedComment).range().startCharacter().isEqualTo(2);
+    assertThat(portedComment).range().endLine().isEqualTo(4);
+    assertThat(portedComment).range().endCharacter().isEqualTo(5);
+  }
+
+  @Test
+  public void portedRangeCommentCanHandleRenameWithLineShift() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .delete()
+            .file("newFileName")
+            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(3)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("newFileName");
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).range().startLine().isEqualTo(5);
+    assertThat(portedComment).range().startCharacter().isEqualTo(2);
+    assertThat(portedComment).range().endLine().isEqualTo(6);
+    assertThat(portedComment).range().endCharacter().isEqualTo(5);
+  }
+
+  @Test
+  public void portedRangeCommentAdditionallyAppearsOnCopyAtIndependentPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    // Gerrit currently only identifies a copy if a rename also happens at the same time. Modify the
+    // renamed file slightly different than the copied file so that the end location of the comment
+    // is different. Modify the renamed file less so that Gerrit/Git picks it as the renamed one.
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .delete()
+            .file("renamedFiled")
+            .content("Line 1\nLine 1.1\nLine 2\nLine 3\nLine 4\n")
+            .file("copiedFile")
+            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(3)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("renamedFiled", "copiedFile");
+    CommentInfo portedCommentOnRename = getOnlyElement(portedComments.get("renamedFiled"));
+    assertThat(portedCommentOnRename).uuid().isEqualTo(commentUuid);
+    assertThat(portedCommentOnRename).range().startLine().isEqualTo(4);
+    assertThat(portedCommentOnRename).range().startCharacter().isEqualTo(2);
+    assertThat(portedCommentOnRename).range().endLine().isEqualTo(5);
+    assertThat(portedCommentOnRename).range().endCharacter().isEqualTo(5);
+    CommentInfo portedCommentOnCopy = getOnlyElement(portedComments.get("copiedFile"));
+    assertThat(portedCommentOnCopy).uuid().isEqualTo(commentUuid);
+    assertThat(portedCommentOnCopy).range().startLine().isEqualTo(5);
+    assertThat(portedCommentOnCopy).range().startCharacter().isEqualTo(2);
+    assertThat(portedCommentOnCopy).range().endLine().isEqualTo(6);
+    assertThat(portedCommentOnCopy).range().endCharacter().isEqualTo(5);
+  }
+
+  @Test
+  public void lineOfPortedRangeCommentFollowsContract() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(3)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    // Line is equal to the end line, which is at 6 when ported.
+    assertThat(portedComment).line().isEqualTo(6);
+  }
+
+  @Test
+  public void portedRangeCommentBecomesFileCommentOnConflict() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine two\nLine three\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(2)
+            .charOffset(2)
+            .toLine(3)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("myFile");
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).range().isNull();
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedRangeCommentEndingOnLineJustBeforeModificationCanBePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 2\nLine three\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(1)
+            .charOffset(2)
+            .toLine(2)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).range().startLine().isEqualTo(1);
+    assertThat(portedComment).range().startCharacter().isEqualTo(2);
+    assertThat(portedComment).range().endLine().isEqualTo(2);
+    assertThat(portedComment).range().endCharacter().isEqualTo(5);
+  }
+
+  @Test
+  public void portedRangeCommentEndingAtStartOfModifiedLineCanBePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 2\nLine three\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(1)
+            .charOffset(2)
+            .toLine(3)
+            .charOffset(0)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).range().startLine().isEqualTo(1);
+    assertThat(portedComment).range().startCharacter().isEqualTo(2);
+    assertThat(portedComment).range().endLine().isEqualTo(3);
+    assertThat(portedComment).range().endCharacter().isEqualTo(0);
+  }
+
+  @Test
+  public void portedRangeCommentEndingWithinModifiedLineBecomesFileComment() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 2\nLine three\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(1)
+            .charOffset(2)
+            .toLine(3)
+            .charOffset(4)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).range().isNull();
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedRangeCommentWithinModifiedLineBecomesFileComment() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 2\nLine three\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(3)
+            .charOffset(2)
+            .toLine(3)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).range().isNull();
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedRangeCommentStartingWithinLastModifiedLineBecomesFileComment()
+      throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line one\nLine two\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(2)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).range().isNull();
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedRangeCommentStartingOnLineJustAfterModificationCanBePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine two\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(3)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).range().startLine().isEqualTo(3);
+    assertThat(portedComment).range().startCharacter().isEqualTo(2);
+    assertThat(portedComment).range().endLine().isEqualTo(4);
+    assertThat(portedComment).range().endCharacter().isEqualTo(5);
+  }
+
+  // We could actually do better in such a situation but that involves some careful improvements
+  // which would need to be covered with even more tests (e.g. several modifications could be within
+  // the comment range; several comments could surround it; other modifications could have occurred
+  // in the file so that start is shifted too but different than end). That's why we go for the
+  // simple solution now (-> just map to file comment).
+  @Test
+  public void portedRangeCommentStartingBeforeButEndingAfterModifiedLineBecomesFileComment()
+      throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 2\nLine three\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(2)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).range().isNull();
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedRangeCommentBecomesPatchsetLevelCommentOnFileDeletion() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").delete().create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            .fromLine(3)
+            .charOffset(2)
+            .toLine(4)
+            .charOffset(5)
+            .ofFile("myFile")
+            .create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).range().isNull();
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void overlappingRangeCommentsArePortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 2\n")
+            .create();
+    // Add comment.
+    String commentUuid1 =
+        newComment(patchset1Id)
+            .fromLine(2)
+            .charOffset(0)
+            .toLine(2)
+            .charOffset(3)
+            .ofFile("myFile")
+            .create();
+    String commentUuid2 =
+        newComment(patchset1Id)
+            .fromLine(2)
+            .charOffset(1)
+            .toLine(2)
+            .charOffset(4)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
+    assertThat(portedComment1).range().startLine().isEqualTo(3);
+    CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
+    assertThat(portedComment2).range().startLine().isEqualTo(3);
+  }
+
+  @Test
+  public void portedLineCommentCanHandleAddedLines() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(5);
+  }
+
+  @Test
+  public void portedLineCommentCanHandleDeletedLines() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+  }
+
+  @Test
+  public void portedLineCommentCanHandlePureRename() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("newFileName");
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isEqualTo(3);
+  }
+
+  @Test
+  public void portedLineCommentCanHandleRenameWithLineShift() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    changeOps
+        .change(changeId)
+        .newPatchset()
+        .file("myFile")
+        .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+        .create();
+    PatchSet.Id patchset3Id =
+        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset3Id);
+
+    assertThatMap(portedComments).keys().containsExactly("newFileName");
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isEqualTo(5);
+  }
+
+  @Test
+  public void portedLineCommentAdditionallyAppearsOnCopyAtIndependentPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    // Gerrit currently only identifies a copy if a rename also happens at the same time. Modify the
+    // renamed file slightly different than the copied file so that the end location of the comment
+    // is different. Modify the renamed file less so that Gerrit/Git picks it as the renamed one.
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .delete()
+            .file("renamedFiled")
+            .content("Line 1\nLine 1.1\nLine 2\nLine 3\nLine 4\n")
+            .file("copiedFile")
+            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("renamedFiled", "copiedFile");
+    CommentInfo portedCommentOnRename = getOnlyElement(portedComments.get("renamedFiled"));
+    assertThat(portedCommentOnRename).uuid().isEqualTo(commentUuid);
+    assertThat(portedCommentOnRename).line().isEqualTo(4);
+    CommentInfo portedCommentOnCopy = getOnlyElement(portedComments.get("copiedFile"));
+    assertThat(portedCommentOnCopy).uuid().isEqualTo(commentUuid);
+    assertThat(portedCommentOnCopy).line().isEqualTo(5);
+  }
+
+  @Test
+  public void portedLineCommentBecomesFileCommentOnConflict() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine two\nLine three\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("myFile");
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedLineCommentOnLineJustBeforeModificationCanBePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 2\nLine three\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+  }
+
+  @Test
+  public void portedLineCommentOnStartLineOfModificationBecomesFileComment() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
+            .create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedLineCommentOnLastLineOfModificationBecomesFileComment() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
+            .create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedLineCommentOnLineJustAfterModificationCanBePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 2\nLine three\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(4);
+  }
+
+  @Test
+  public void portedLineCommentBecomesPatchsetLevelCommentOnFileDeletion() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").delete().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).range().isNull();
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void overlappingLineCommentsArePortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 2\n")
+            .create();
+    // Add comment.
+    String commentUuid1 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+    String commentUuid2 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+
+    CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
+    assertThat(portedComment1).line().isEqualTo(3);
+    CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
+    assertThat(portedComment2).line().isEqualTo(3);
+  }
+
+  @Test
+  public void portedFileCommentIsObliviousToAdjustedFileContent() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedFileCommentCanHandleRename() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("newFileName");
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedFileCommentAdditionallyAppearsOnCopy() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .renameTo("renamedFiled")
+            .file("copiedFile")
+            .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("renamedFiled", "copiedFile");
+    CommentInfo portedCommentOnCopy = getOnlyElement(portedComments.get("copiedFile"));
+    assertThat(portedCommentOnCopy).line().isNull();
+  }
+
+  @Test
+  public void portedFileCommentBecomesPatchsetLevelCommentOnFileDeletion() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").delete().create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).range().isNull();
+    assertThat(portedComment).line().isNull();
+  }
+
+  @Test
+  public void portedPatchsetLevelCommentIsObliviousToAdjustedFileContent() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    // Add comment.
+    newComment(patchset1Id).onPatchsetLevel().create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void portedPatchsetLevelCommentIsObliviousToRename() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+    // Add comment.
+    newComment(patchset1Id).onPatchsetLevel().create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void commentOnCommitMessageIsPortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId =
+        changeOps.newChange().commitMessage("Summary line\n\nText 1\nText 2").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .commitMessage("Summary line\n\nText 1\nText 1.1\nText 2")
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(patchset1Id)
+            // The /COMMIT_MSG file has a header of 6 lines, so the summary line is in line 7.
+            // Place comment on 'Text 2' which is line 10.
+            .onLine(10)
+            .ofFile(Patch.COMMIT_MSG)
+            .create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(11);
+  }
+
+  @Test
+  public void commentOnParentIsPortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id parentChangeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .childOf()
+            .change(parentChangeId)
+            .file("myFile")
+            .content("Line one\n")
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id parentPatchset2Id =
+        changeOps
+            .change(parentChangeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 0\nLine 1\n")
+            .create();
+    PatchSet.Id childPatchset2Id =
+        changeOps.change(childChangeId).newPatchset().parent().patchset(parentPatchset2Id).create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+  }
+
+  @Test
+  public void commentOnFirstParentIsPortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .file("file1")
+            .content("Line one\n")
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id parent1Patchset2Id =
+        changeOps
+            .change(parent1ChangeId)
+            .newPatchset()
+            .file("file1")
+            .content("Line 0\nLine 1\n")
+            .create();
+    PatchSet.Id childPatchset2Id =
+        changeOps
+            .change(childChangeId)
+            .newPatchset()
+            .parents()
+            .patchset(parent1Patchset2Id)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();
+
+    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+  }
+
+  @Test
+  public void commentOnSecondParentIsPortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .file("file2")
+            .content("Line one\n")
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id parent2Patchset2Id =
+        changeOps
+            .change(parent1ChangeId)
+            .newPatchset()
+            .file("file2")
+            .content("Line 0\nLine 1\n")
+            .create();
+    PatchSet.Id childPatchset2Id =
+        changeOps
+            .change(childChangeId)
+            .newPatchset()
+            .parents()
+            .change(parent1ChangeId)
+            .and()
+            .patchset(parent2Patchset2Id)
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();
+
+    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+  }
+
+  @Test
+  public void commentOnAutoMergeCommitIsPortedToNewPosition() throws Exception {
+    // Set up change and patchsets. Use the same file so that there's a meaningful auto-merge
+    // commit/diff.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id parent1Patchset2Id =
+        changeOps
+            .change(parent1ChangeId)
+            .newPatchset()
+            .file("file1")
+            .content("Line 0\nLine 1\n")
+            .create();
+    PatchSet.Id parent2Patchset2Id =
+        changeOps
+            .change(parent1ChangeId)
+            .newPatchset()
+            .file("file1")
+            .content("Line zero\nLine 1\n")
+            .create();
+    PatchSet.Id childPatchset2Id =
+        changeOps
+            .change(childChangeId)
+            .newPatchset()
+            .parents()
+            .patchset(parent1Patchset2Id)
+            .and()
+            .patchset(parent2Patchset2Id)
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();
+
+    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+    // Merging the parents creates a conflict in the file. -> Several lines are added due to
+    // conflict markers in the auto-merge commit. We don't care about the exact number, just that
+    // the comment moved down several lines (instead of just one in each parent) and that the
+    // porting logic hence used the auto-merge commit for its computation.
+    assertThat(portedComment).line().isGreaterThan(2);
+  }
+
+  @Test
+  public void commentOnFirstParentIsPortedToSingleParentWhenPatchsetChangedToNonMergeCommit()
+      throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id parent1PatchsetId2 =
+        changeOps
+            .change(parent1ChangeId)
+            .newPatchset()
+            .file("file1")
+            .content("Line 0\nLine 1\n")
+            .create();
+    PatchSet.Id childPatchset2Id =
+        changeOps
+            .change(childChangeId)
+            .newPatchset()
+            .parent()
+            .patchset(parent1PatchsetId2)
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();
+
+    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+    assertThat(portedComment).side().isEqualTo(Side.PARENT);
+    assertThat(portedComment).parent().isEqualTo(1);
+  }
+
+  @Test
+  public void commentOnSecondParentBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
+      throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id childPatchset2Id =
+        changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isNull();
+    assertThat(portedComment).side().isNull();
+    assertThat(portedComment).parent().isNull();
+  }
+
+  @Test
+  // TODO(ghareeb): Adjust implementation in CommentsUtil to use the new auto-merge code instead of
+  // PatchListCache#getOldId which returns the wrong result if a change isn't a merge commit.
+  @Ignore
+  public void
+      commentOnAutoMergeCommitBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
+          throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id childPatchset2Id =
+        changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isNull();
+    assertThat(portedComment).side().isNull();
+    assertThat(portedComment).parent().isNull();
+  }
+
+  @Test
+  public void whitespaceOnlyModificationsAreAlsoConsideredWhenPorting() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").content("\nLine 1\n").create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(1).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+  }
+
+  @Test
+  public void deletedCommentContentIsNotCachedInPortedComments() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchsetId1).message("Confidential content").create();
+
+    getPortedComment(patchsetId2, commentUuid);
+    gApi.changes()
+        .id(changeId.get())
+        .revision(patchsetId1.get())
+        .comment(commentUuid)
+        .delete(new DeleteCommentInput());
+    CommentInfo portedComment = getPortedComment(patchsetId2, commentUuid);
+
+    assertThat(portedComment).message().doesNotContain("Confidential content");
+  }
+
+  @Test
+  public void setOfPortedCommentsCanChangeOnRepeatedCalls() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid1 = newComment(patchsetId1).unresolved().create();
+
+    ImmutableList<CommentInfo> pastPortedComments = flatten(getPortedComments(patchsetId2));
+    // Set the existing comment thread to resolved, so it won't be ported anymore.
+    newComment(patchsetId1).parentUuid(commentUuid1).resolved().create();
+    // Create a new comment which should show up as ported comment.
+    String commentUuid2 = newComment(patchsetId1).create();
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));
+
+    // Ensure that results are not cached between calls. This should not be necessary as the diffs
+    // are already cached. If we need to also cache the ported comments in the future, we'll need to
+    // identify ALL situations when the set of ported comments changes.
+    assertThat(portedComments).isNotEqualTo(pastPortedComments);
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid2);
+  }
+
+  private TestCommentCreation.Builder newComment(PatchSet.Id patchsetId) {
+    // Create unresolved comments by default as only those are ported. Tests get override the
+    // unresolved state by explicitly setting it.
+    return changeOps.change(patchsetId.changeId()).patchset(patchsetId).newComment().unresolved();
+  }
+
+  private TestCommentCreation.Builder newDraftComment(PatchSet.Id patchsetId) {
+    // Create unresolved comments by default as only those are ported. Tests get override the
+    // unresolved state by explicitly setting it.
+    return changeOps
+        .change(patchsetId.changeId())
+        .patchset(patchsetId)
+        .newDraftComment()
+        .unresolved();
+  }
+
+  private CommentInfo getPortedComment(PatchSet.Id patchsetId, String commentUuid)
+      throws RestApiException {
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchsetId);
+    return extractSpecificComment(portedComments, commentUuid);
+  }
+
+  private Map<String, List<CommentInfo>> getPortedComments(PatchSet.Id patchsetId)
+      throws RestApiException {
+    return gApi.changes()
+        .id(patchsetId.changeId().get())
+        .revision(patchsetId.get())
+        .portedComments();
+  }
+
+  private Map<String, List<CommentInfo>> getPortedDraftCommentsOfUser(
+      PatchSet.Id patchsetId, Account.Id accountId) throws RestApiException {
+    // Draft comments are only visible to their author.
+    requestScopeOps.setApiUser(accountId);
+    return gApi.changes().id(patchsetId.changeId().get()).revision(patchsetId.get()).portedDrafts();
+  }
+
+  private static CommentInfo extractSpecificComment(
+      Map<String, List<CommentInfo>> portedComments, String commentUuid) {
+    return portedComments.values().stream()
+        .flatMap(Collection::stream)
+        .filter(comment -> comment.id.equals(commentUuid))
+        .collect(onlyElement());
+  }
+
+  /**
+   * Returns all comments in one list. The map keys (= file paths) are simply ignored. The returned
+   * comments won't have the file path attribute set for them as they came from a map with that
+   * attribute as key (= established Gerrit behavior).
+   */
+  private static ImmutableList<CommentInfo> flatten(
+      Map<String, List<CommentInfo>> commentsPerFile) {
+    return commentsPerFile.values().stream()
+        .flatMap(Collection::stream)
+        .collect((toImmutableList()));
+  }
+
+  // Unfortunately, we don't get an absolutely helpful error message when using this correspondence
+  // as CommentInfo doesn't have a toString() implementation. Even if we added it, the string
+  // representation would be quite unwieldy due to the huge number of comment attributes.
+  // Interestingly, using Correspondence#formattingDiffsUsing didn't improve anything.
+  private static Correspondence<CommentInfo, String> hasUuid() {
+    return NullAwareCorrespondence.transforming(comment -> comment.id, "hasUuid");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index a53f298..2976d78 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 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;
@@ -35,16 +36,12 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 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;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
@@ -63,6 +60,7 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Test;
 
 public class RevisionDiffIT extends AbstractDaemonTest {
@@ -121,6 +119,24 @@
   }
 
   @Test
+  public void patchsetLevelFileDiffIsEmpty() throws Exception {
+    PushOneCommit.Result result = createChange();
+    DiffInfo diffForPatchsetLevelFile =
+        gApi.changes()
+            .id(result.getChangeId())
+            .revision(result.getCommit().name())
+            .file(PATCHSET_LEVEL)
+            .diff();
+    // This behavior is the same as the behavior for non-existent files.
+    assertThat(diffForPatchsetLevelFile).binary().isNull();
+    assertThat(diffForPatchsetLevelFile).content().isEmpty();
+    assertThat(diffForPatchsetLevelFile).diffHeader().isNull();
+    assertThat(diffForPatchsetLevelFile).metaA().isNull();
+    assertThat(diffForPatchsetLevelFile).metaB().isNull();
+    assertThat(diffForPatchsetLevelFile).webLinks().isNull();
+  }
+
+  @Test
   public void deletedFileIsIncludedInDiff() throws Exception {
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
     gApi.changes().id(changeId).edit().publish();
@@ -353,6 +369,31 @@
   }
 
   @Test
+  public void copiedFileDetectedIfOriginalFileIsRenamedInDiff() throws Exception {
+    /*
+     * Copies are detected when a file is deleted and more than 1 file with the same content are
+     * added. In this case, the added file with the closest name to the original file is tagged as a
+     * rename and the remaining files are considered copies. This implementation is done by JGit in
+     * the RenameDetector component.
+     */
+    String renamedFileName = "renamed_some_file.txt";
+    String copyFileName1 = "copy1_with_different_name.txt";
+    String copyFileName2 = "copy2_with_different_name.txt";
+    gApi.changes().id(changeId).edit().modifyFile(copyFileName1, RawInputUtil.create(FILE_CONTENT));
+    gApi.changes().id(changeId).edit().modifyFile(copyFileName2, RawInputUtil.create(FILE_CONTENT));
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+
+    assertThat(changedFiles.keySet())
+        .containsExactly("/COMMIT_MSG", renamedFileName, copyFileName1, copyFileName2);
+    assertThat(changedFiles.get(renamedFileName).status).isEqualTo('R');
+    assertThat(changedFiles.get(copyFileName1).status).isEqualTo('C');
+    assertThat(changedFiles.get(copyFileName2).status).isEqualTo('C');
+  }
+
+  @Test
   public void addedBinaryFileIsIncludedInDiff() throws Exception {
     String imageFileName = "an_image.png";
     byte[] imageBytes = createRgbImage(255, 0, 0);
@@ -632,12 +673,6 @@
     String baseFileContent = FILE_CONTENT.concat("Line 101");
     ObjectId commit2 = addCommit(commit1, FILE_NAME, baseFileContent);
     rebaseChangeOn(changeId, commit2);
-    // Add a comment so that file contents are not 'skipped'. To be able to add a comment, touch
-    // (= modify) the file in the change.
-    addModifiedPatchSet(
-        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 2\n", "Line two\n"));
-    CommentInput comment = createCommentInput(3, 0, 4, 0, "Comment to not skip file content.");
-    addCommentTo(changeId, CURRENT, FILE_NAME, comment);
     String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
     String newBaseFileContent = baseFileContent.concat("\n");
     ObjectId commit3 = addCommit(commit2, FILE_NAME, newBaseFileContent);
@@ -2473,17 +2508,14 @@
   }
 
   @Test
-  public void diffOfUnmodifiedFileWithWholeFileContextReturnsFileContents() throws Exception {
+  public void diffOfUnmodifiedFileReturnsAllFileContents() throws Exception {
     addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
     String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
     addModifiedPatchSet(
         changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
 
     DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME)
-            .withBase(previousPatchSetId)
-            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
-            .get();
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
     // We don't list the full file contents here as that is not the focus of this test.
     assertThat(diffInfo)
         .content()
@@ -2494,40 +2526,16 @@
   }
 
   @Test
-  public void diffOfUnmodifiedFileWithCommentAndWholeFileContextReturnsFileContents()
+  // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
+  // filter such files in the GetFiles REST endpoint.
+  @Ignore
+  public void diffOfFileWithOnlyRebaseHunksConsideringWhitespaceReturnsFileContents()
       throws Exception {
     addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
     String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
-    addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
-    addModifiedPatchSet(
-        changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME)
-            .withBase(previousPatchSetId)
-            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
-            .get();
-    // We don't list the full file contents here as that is not the focus of this test.
-    assertThat(diffInfo)
-        .content()
-        .element(0)
-        .commonLines()
-        .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
-        .inOrder();
-  }
-
-  @Test
-  public void
-      diffOfFileWithOnlyRebaseHunksAndWithCommentAndConsideringWhitespaceReturnsFileContents()
-          throws Exception {
-    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
     String newBaseFileContent = FILE_CONTENT.replace("Line 70\n", "Line seventy\n");
     ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
     rebaseChangeOn(changeId, commit2);
-    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
-    addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME)
@@ -2541,18 +2549,23 @@
         .commonLines()
         .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
         .inOrder();
+    // It's crucial that the line changed in the rebase is reported correctly.
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 70");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line seventy");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
   }
 
   @Test
-  public void diffOfFileWithOnlyRebaseHunksAndWithCommentAndIgnoringWhitespaceReturnsFileContents()
+  // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
+  // filter such files in the GetFiles REST endpoint.
+  @Ignore
+  public void diffOfFileWithOnlyRebaseHunksAndIgnoringWhitespaceReturnsFileContents()
       throws Exception {
     addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
     String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
     String newBaseFileContent = FILE_CONTENT.replace("Line 70\n", "Line seventy\n");
     ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
     rebaseChangeOn(changeId, commit2);
-    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
-    addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME)
@@ -2566,12 +2579,18 @@
         .commonLines()
         .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
         .inOrder();
+    // It's crucial that the line changed in the rebase is reported correctly.
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 70");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line seventy");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
   }
 
   @Test
-  public void
-      diffOfFileWithMultilineRebaseHunkAddingNewlineAtEndOfFileAndWithCommentReturnsFileContents()
-          throws Exception {
+  // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
+  // filter such files in the GetFiles REST endpoint.
+  @Ignore
+  public void diffOfFileWithMultilineRebaseHunkAddingNewlineAtEndOfFileReturnsFileContents()
+      throws Exception {
     String baseFileContent = FILE_CONTENT.concat("Line 101");
     ObjectId commit2 = addCommit(commit1, FILE_NAME, baseFileContent);
     rebaseChangeOn(changeId, commit2);
@@ -2580,8 +2599,6 @@
     String newBaseFileContent = baseFileContent.concat("\nLine 102\nLine 103\n");
     ObjectId commit3 = addCommit(commit2, FILE_NAME, newBaseFileContent);
     rebaseChangeOn(changeId, commit3);
-    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
-    addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME)
@@ -2595,19 +2612,27 @@
         .commonLines()
         .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
         .inOrder();
+    // It's crucial that the lines changed in the rebase are reported correctly.
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line 101", "Line 102", "Line 103", "");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
   }
 
   @Test
-  public void
-      diffOfFileWithMultilineRebaseHunkRemovingNewlineAtEndOfFileAndWithCommentReturnsFileContents()
-          throws Exception {
+  // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
+  // filter such files in the GetFiles REST endpoint.
+  @Ignore
+  public void diffOfFileWithMultilineRebaseHunkRemovingNewlineAtEndOfFileReturnsFileContents()
+      throws Exception {
     addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
     String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-    String newBaseFileContent = FILE_CONTENT.concat("Line 101\nLine 103\nLine 104");
+    String newBaseFileContent = FILE_CONTENT.concat("Line 101\nLine 102\nLine 103");
     ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
     rebaseChangeOn(changeId, commit2);
-    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
-    addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME)
@@ -2621,6 +2646,14 @@
         .commonLines()
         .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
         .inOrder();
+    // It's crucial that the lines changed in the rebase are reported correctly.
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100", "");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line 100", "Line 101", "Line 102", "Line 103");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
   }
 
   @Test
@@ -2630,49 +2663,10 @@
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, "a_non-existent_file.txt")
             .withBase(initialPatchSetId)
-            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
             .get();
     assertThat(diffInfo).content().isEmpty();
   }
 
-  // This behavior is likely a bug. A fix might not be easy as it might break syntax highlighting.
-  // TODO: Fix this issue or remove the broken parameter (at least in the documentation).
-  @Test
-  public void contextParameterIsIgnored() throws Exception {
-    addModifiedPatchSet(
-        changeId, FILE_NAME, content -> content.replace("Line 20\n", "Line twenty\n"));
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME)
-            .withBase(initialPatchSetId)
-            .withContext(5)
-            .get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(19);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 20");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line twenty");
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(81);
-  }
-
-  // This behavior is likely a bug. A fix might not be easy as it might break syntax highlighting.
-  // TODO: Fix this issue or remove the broken parameter (at least in the documentation).
-  @Test
-  public void contextParameterIsIgnoredForUnmodifiedFileWithComment() throws Exception {
-    addModifiedPatchSet(
-        changeId, FILE_NAME, content -> content.replace("Line 20\n", "Line twenty\n"));
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-    CommentInput comment = createCommentInput(20, 0, 21, 0, "Should be 'Line 20'.");
-    addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
-    addModifiedPatchSet(
-        changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME)
-            .withBase(previousPatchSetId)
-            .withContext(5)
-            .get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(101);
-  }
-
   @Test
   public void requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult() throws Exception {
     addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
@@ -2682,40 +2676,13 @@
     gApi.changes().id(changeId).edit().publish();
 
     DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME)
-            .withBase(previousPatchSetId)
-            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
-            .get();
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
     // This behavior has been present in Gerrit for quite some time. It differs from the results
-    // returned for other cases (e.g. requesting the diff with whole file context for an unmodified
-    // file; requesting the diff with whole file context for a non-existent file). However, it's not
-    // completely clear what should be returned. The closest would be the result of a file deletion
-    // but that might also be misleading for users as actually a file rename occurred. In fact,
-    // requesting the diff result for the old file name of a renamed file is not a reasonable use
-    // case at all. We at least guarantee that we don't run into an internal error.
-    assertThat(diffInfo).content().element(0).commonLines().isNull();
-    assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
-  }
-
-  @Test
-  public void requestingDiffForOldFileNameOfRenamedFileWithCommentOnOldFileYieldsReasonableResult()
-      throws Exception {
-    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
-    addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
-    String newFilePath = "a_new_file.txt";
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
-    gApi.changes().id(changeId).edit().publish();
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME)
-            .withBase(previousPatchSetId)
-            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
-            .get();
-    // See comment for requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult().
-    // This test should additionally ensure that we also don't run into an internal error when
-    // a comment is present.
+    // returned for other cases (e.g. requesting the diff for an unmodified file; requesting the
+    // diff for a non-existent file). After a rename, the original file doesn't exist anymore.
+    // Hence, the most reasonable thing would be to match the behavior of requesting the diff for a
+    // non-existent file, which returns an empty diff.
+    // This test at least guarantees that we don't run into an internal error.
     assertThat(diffInfo).content().element(0).commonLines().isNull();
     assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
   }
@@ -2741,26 +2708,6 @@
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
 
-  private static CommentInput createCommentInput(
-      int startLine, int startCharacter, int endLine, int endCharacter, String message) {
-    CommentInput comment = new CommentInput();
-    comment.range = new Comment.Range();
-    comment.range.startLine = startLine;
-    comment.range.startCharacter = startCharacter;
-    comment.range.endLine = endLine;
-    comment.range.endCharacter = endCharacter;
-    comment.message = message;
-    return comment;
-  }
-
-  private void addCommentTo(
-      String changeId, String previousPatchSetId, String fileName, CommentInput comment)
-      throws RestApiException {
-    ReviewInput reviewInput = new ReviewInput();
-    reviewInput.comments = ImmutableMap.of(fileName, ImmutableList.of(comment));
-    gApi.changes().id(changeId).revision(previousPatchSetId).review(reviewInput);
-  }
-
   private void assertDiffForNewFile(
       PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
     DiffInfo diff =
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 74f9134..2f9530c 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
@@ -49,11 +50,12 @@
 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.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.BranchOrderSection;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -94,6 +96,7 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.GetRevisionActions;
+import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
 import java.sql.Timestamp;
@@ -1295,8 +1298,25 @@
     assertThat(changes).hasSize(1);
     assertThat(changes.get(0).changeId).isEqualTo(r2.getChangeId());
     assertThat(changes.get(0).mergeable).isEqualTo(Boolean.TRUE);
+  }
 
-    // TODO(dborowitz): Test for other-branches.
+  @Test
+  public void mergeableOtherBranches() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "mergeable-other-branch"), head);
+    createBranchWithRevision(BranchNameKey.create(project, "ignored"), head);
+    PushOneCommit.Result change1 = createChange();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .setBranchOrderSection(
+              BranchOrderSection.create(
+                  ImmutableList.of("master", "nonexistent", "mergeable-other-branch")));
+      u.save();
+    }
+
+    MergeableInfo mergeableInfo =
+        gApi.changes().id(change1.getChangeId()).current().mergeableOtherBranches();
+    assertThat(mergeableInfo.mergeableInto).containsExactly("mergeable-other-branch");
   }
 
   @Test
@@ -1500,6 +1520,19 @@
   }
 
   @Test
+  public void patchsetLevelContentDoesNotExist() throws Exception {
+    PushOneCommit.Result change = createChange();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () ->
+            gApi.changes()
+                .id(change.getChangeId())
+                .revision(change.getCommit().name())
+                .file(PATCHSET_LEVEL)
+                .content());
+  }
+
+  @Test
   public void cannotGetContentOfDirectory() throws Exception {
     Map<String, String> files = ImmutableMap.of("dir/file1.txt", "content 1");
     PushOneCommit.Result result =
@@ -1860,6 +1893,56 @@
     assertThat(approvals).hasSize(2);
   }
 
+  @Test
+  public void uploaderNotAddedAsReviewer() throws Exception {
+    PushOneCommit.Result result = createChange();
+    amendChangeWithUploader(result, project, user);
+    assertThat(result.getChange().reviewers().all()).isEmpty();
+  }
+
+  @Test
+  public void notificationsOnPushNewPatchset() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+    sender.clear();
+
+    // check that reviewer is notified.
+    amendChange(r.getChangeId());
+    List<FakeEmailSender.Message> messages = sender.getMessages();
+    FakeEmailSender.Message m = Iterables.getOnlyElement(messages);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains("I'd like you to reexamine a change.");
+  }
+
+  @Test
+  @GerritConfig(name = "change.sendNewPatchsetEmails", value = "false")
+  public void notificationsOnPushNewPatchsetNotSentWithSendNewPatchsetEmailsAsFalse()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+    sender.clear();
+
+    // check that reviewer is not notified
+    amendChange(r.getChangeId());
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "change.sendNewPatchsetEmails", value = "false")
+  public void notificationsOnPushNewPatchsetAlwaysSentToProjectWatchers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    watch(project.get());
+    sender.clear();
+
+    // check that watcher is notified
+    amendChange(r.getChangeId());
+    List<FakeEmailSender.Message> messages = sender.getMessages();
+    FakeEmailSender.Message m = Iterables.getOnlyElement(messages);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains(admin.fullName() + " has uploaded a new patch set (#2).");
+  }
+
   private static void assertCherryPickResult(
       ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) throws Exception {
     assertThat(changeInfo.changeId).isEqualTo(srcChangeId);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 0c98d32..85a7b29 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.acceptance.api.revision;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 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.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 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;
@@ -31,10 +34,14 @@
 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.change.ChangeOperations;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+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.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.ChangeType;
@@ -65,6 +72,7 @@
 
 public class RobotCommentsIT extends AbstractDaemonTest {
   @Inject private TestCommentHelper testCommentHelper;
+  @Inject private ChangeOperations changeOperations;
 
   private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
   private static final String GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
@@ -151,7 +159,7 @@
     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);
+    TestTimeUtil.incrementClock(10, TimeUnit.SECONDS);
 
     RobotCommentInput c1 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
     RobotCommentInput c2 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
@@ -220,9 +228,9 @@
             .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
+     * All change messages have the auto-generated tag. Robot comments can be linked to
+     * auto-generated messages where each comment is linked to the next nearest change message in
+     * timestamp
      */
     assertThat(message1ChangeId).isEqualTo(comment1MessageId);
     assertThat(message2ChangeId).isEqualTo(comment2MessageId);
@@ -267,6 +275,109 @@
   }
 
   @Test
+  public void patchsetLevelRobotCommentCanBeAddedAndRetrieved() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+    testCommentHelper.addRobotComment(changeId, input);
+
+    List<RobotCommentInfo> results = getRobotComments();
+    assertThatList(results).onlyElement().path().isEqualTo(PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void patchsetLevelRobotCommentCantHaveLine() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+    input.line = 1;
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+    assertThat(ex.getMessage()).contains("line");
+  }
+
+  @Test
+  public void patchsetLevelRobotCommentCantHaveRange() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+    input.range = createRange(2, 9, 5, 10);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelRobotCommentCantHaveSide() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+    input.side = Side.REVISION;
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+    assertThat(ex.getMessage()).contains("side");
+  }
+
+  @Test
+  public void fixSuggestionCannotPointToPatchsetLevel() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    FixReplacementInfo brokenFixReplacement = createFixReplacementInfo();
+    brokenFixReplacement.path = PATCHSET_LEVEL;
+    input.fixSuggestions = ImmutableList.of(createFixSuggestionInfo(brokenFixReplacement));
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+    assertThat(ex.getMessage()).contains("file path must not be " + PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void robotCommentInvalidInReplyTo() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+    input.inReplyTo = "invalid";
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+    assertThat(ex.getMessage()).contains("inReplyTo");
+  }
+
+  @Test
+  public void canCreateRobotCommentWithRobotCommentAsParent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentRobotCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+    ReviewInput.RobotCommentInput robotCommentInput =
+        TestCommentHelper.createRobotCommentInputWithMandatoryFields(COMMIT_MSG);
+    robotCommentInput.message = "comment reply";
+    robotCommentInput.inReplyTo = parentRobotCommentUuid;
+    testCommentHelper.addRobotComment(changeId, robotCommentInput);
+
+    RobotCommentInfo resultComment =
+        Iterables.getOnlyElement(
+            gApi.changes().id(changeId.get()).current().robotCommentsAsList().stream()
+                .filter(c -> c.message.equals("comment reply"))
+                .collect(toImmutableSet()));
+    assertThat(resultComment.inReplyTo).isEqualTo(parentRobotCommentUuid);
+  }
+
+  @Test
+  public void canCreateRobotCommentWithHumanCommentAsParent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String changeIdString = changeOperations.change(changeId).get().changeId();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().create();
+
+    ReviewInput.RobotCommentInput robotCommentInput =
+        TestCommentHelper.createRobotCommentInputWithMandatoryFields(COMMIT_MSG);
+    robotCommentInput.message = "comment reply";
+    robotCommentInput.inReplyTo = parentCommentUuid;
+    testCommentHelper.addRobotComment(changeIdString, robotCommentInput);
+
+    RobotCommentInfo resultComment =
+        Iterables.getOnlyElement(
+            gApi.changes().id(changeIdString).current().robotCommentsAsList().stream()
+                .filter(c -> c.message.equals("comment reply"))
+                .collect(toImmutableSet()));
+    assertThat(resultComment.inReplyTo).isEqualTo(parentCommentUuid);
+  }
+
+  @Test
   public void hugeRobotCommentIsRejected() {
     int defaultSizeLimit = 1 << 20;
     fixReplacementInfo.replacement = getStringFor(defaultSizeLimit + 1);
@@ -944,10 +1055,7 @@
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
     testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
+    String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
 
     gApi.changes().id(changeId).current().applyFix(fixId);
 
@@ -956,6 +1064,196 @@
   }
 
   @Test
+  public void fixOnCommitMessageCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    withFixRobotCommentInput.path = Patch.COMMIT_MSG;
+    fixReplacementInfo.path = Patch.COMMIT_MSG;
+    fixReplacementInfo.replacement = "Modified line\n";
+    fixReplacementInfo.range = createRange(7, 0, 8, 0);
+
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+    String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line\nLine 2 of commit message\n" + footer);
+  }
+
+  @Test
+  public void fixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "Change-Id: " + changeId;
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\n" + "\n" + footer + "\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    withFixRobotCommentInput.path = Patch.COMMIT_MSG;
+    fixReplacementInfo.path = Patch.COMMIT_MSG;
+    fixReplacementInfo.replacement = "Modified line\n";
+    fixReplacementInfo.range = createRange(1, 0, 2, 0);
+
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+    String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
+
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixId));
+    assertThat(exception).hasMessageThat().contains("header");
+  }
+
+  @Test
+  public void fixContainingSeveralModificationsOfCommitMessageCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+    withFixRobotCommentInput.path = Patch.COMMIT_MSG;
+
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+    String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void fixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "File modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+    String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line 1\nLine 2 of commit message\n" + footer);
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("File modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void twoFixesOnCommitMessageCanBeAppliedOneAfterTheOther() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo2);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput1);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput2);
+    List<String> fixIds = getFixIds(getRobotComments());
+
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void twoConflictingFixesOnCommitMessageCanNotBeAppliedOneAfterTheOther() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "Change-Id: " + changeId;
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n\n"
+            + footer
+            + "\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(7, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Differently modified line 1\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo2);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput1);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput2);
+    List<String> fixIds = getFixIds(getRobotComments());
+
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    assertThrows(
+        ResourceConflictException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(fixIds.get(1)));
+  }
+
+  @Test
   public void applyingFixTwiceIsIdempotent() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
@@ -1094,10 +1392,7 @@
     String footer = "Change-Id: " + changeId;
     updateCommitMessage(
         changeId,
-        "Commit title\n\nCommit message line 1\nLine 2\nLine 3\nLast line\n"
-            + "\n"
-            + footer
-            + "\n");
+        "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.
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index d7cc338..5808ea4 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -40,10 +40,10 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.FileContentInput;
@@ -807,8 +807,9 @@
     String cr = "Code-Review";
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType codeReview = TestLabels.codeReview();
-      codeReview.setCopyAllScoresIfNoCodeChange(true);
-      u.getConfig().getLabelSections().put(cr, codeReview);
+      u.getConfig().upsertLabelType(codeReview);
+      u.getConfig()
+          .updateLabelType(codeReview.getName(), lt -> lt.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -1050,11 +1051,7 @@
   }
 
   private String urlDiff(String changeId, String fileName) {
-    return "/changes/"
-        + changeId
-        + "/revisions/0/files/"
-        + fileName
-        + "/diff?context=ALL&intraline";
+    return "/changes/" + changeId + "/revisions/0/files/" + fileName + "/diff?intraline";
   }
 
   private String urlDiff(String changeId, String revisionId, String fileName) {
@@ -1064,7 +1061,7 @@
         + revisionId
         + "/files/"
         + fileName
-        + "/diff?context=ALL&intraline";
+        + "/diff?intraline";
   }
 
   private <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
index 3b80312..88d0937 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 171cb04..bb1a2eb 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -63,13 +63,14 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -93,7 +94,6 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
@@ -161,7 +161,7 @@
   public void setUpPatchSetLock() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       patchSetLock = TestLabels.patchSetLock();
-      u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
+      u.getConfig().upsertLabelType(patchSetLock);
       u.save();
     }
     projectOperations
@@ -1212,7 +1212,7 @@
         label("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(Q.getName(), Q);
+      u.getConfig().upsertLabelType(Q);
       u.save();
     }
     projectOperations
@@ -1569,6 +1569,27 @@
   }
 
   @Test
+  public void pushWithLinkFooter() throws Exception {
+    String changeId = "I0123456789abcdef0123456789abcdef01234567";
+    String url = cfg.getString("gerrit", null, "canonicalWebUrl");
+    if (!url.endsWith("/")) {
+      url += "/";
+    }
+    createCommit(testRepo, "test commit\n\nLink: " + url + "id/" + changeId);
+    pushForReviewOk(testRepo);
+
+    List<ChangeMessageInfo> messages = getMessages(changeId);
+    assertThat(messages.get(0).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void pushWithWrongHostLinkFooter() throws Exception {
+    String changeId = "I0123456789abcdef0123456789abcdef01234567";
+    createCommit(testRepo, "test commit\n\nLink: https://wronghost/id/" + changeId);
+    pushForReviewRejected(testRepo, "missing Change-Id in message footer");
+  }
+
+  @Test
   public void pushWithChangeIdAboveFooterWithCreateNewChangeForAllNotInTarget() throws Exception {
     enableCreateNewChangeForAllNotInTarget();
     testPushWithChangeIdAboveFooter();
@@ -1698,8 +1719,10 @@
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getProject()
-          .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+          .updateProject(
+              p ->
+                  p.setBooleanConfig(
+                      BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE));
       u.save();
     }
 
@@ -1724,8 +1747,10 @@
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getProject()
-          .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+          .updateProject(
+              p ->
+                  p.setBooleanConfig(
+                      BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE));
       u.save();
     }
 
@@ -1875,9 +1900,8 @@
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = TestLabels.codeReview();
-      codeReview.setCopyMaxScore(true);
-      u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
+      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyMaxScore(true).build();
+      u.getConfig().upsertLabelType(codeReview);
       u.save();
     }
 
@@ -2719,7 +2743,7 @@
   }
 
   private Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
-    return gApi.changes().id(changeId).comments().values().stream()
+    return gApi.changes().id(changeId).commentsRequest().get().values().stream()
         .flatMap(Collection::stream)
         .collect(toList());
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index dcee118..23bcdec 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -26,16 +26,16 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index c5c54c0..c3bcbd3 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -22,9 +22,9 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -204,12 +204,12 @@
       throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(submodule)) {
       md.setMessage("Added superproject subscription");
-      SubscribeSection s;
+      SubscribeSection.Builder s;
       ProjectConfig pc = projectConfigFactory.read(md);
       if (pc.getSubscribeSections().containsKey(superproject)) {
-        s = pc.getSubscribeSections().get(superproject);
+        s = pc.getSubscribeSections().get(superproject).toBuilder();
       } else {
-        s = new SubscribeSection(superproject);
+        s = SubscribeSection.builder(superproject);
       }
       String refspec;
       if (superBranch == null) {
@@ -222,7 +222,7 @@
       } else {
         s.addMultiMatchRefSpec(refspec);
       }
-      pc.addSubscribeSection(s);
+      pc.addSubscribeSection(s.build());
       ObjectId oldId = pc.getRevision();
       ObjectId newId = pc.commit(md);
       assertThat(newId).isNotEqualTo(oldId);
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
index b51263e..80cc508 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -85,8 +85,10 @@
   private void setRejectImplicitMerges() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getProject()
-          .setBooleanConfig(BooleanProjectConfig.REJECT_IMPLICIT_MERGES, InheritableBoolean.TRUE);
+          .updateProject(
+              p ->
+                  p.setBooleanConfig(
+                      BooleanProjectConfig.REJECT_IMPLICIT_MERGES, InheritableBoolean.TRUE));
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
index 6f7a4c3..27962da 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
@@ -33,8 +33,9 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -43,7 +44,6 @@
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.ProjectWatches;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.testing.ConfigSuite;
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index f9c751f..1a2ae7c 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.git.testing.PushResultSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
@@ -26,11 +27,11 @@
 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.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -81,14 +82,17 @@
         .update();
   }
 
+  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void mixingMagicAndRegularPush() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
 
     String msg = "cannot combine normal pushes and magic pushes";
-    assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
-    assertThat(r.getRemoteUpdate("refs/for/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master"))
+        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master"))
+        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
     assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
   }
 
@@ -146,6 +150,22 @@
   }
 
   @Test
+  public void createDeniedIfUserCantRead() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    testRepo.branch("HEAD").commit().create();
+    PushResult r = push("HEAD:refs/for/master");
+    assertThat(r)
+        .onlyRef("refs/for/master")
+        .isRejected("prohibited by Gerrit: not permitted: read on refs/heads/master");
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+  }
+
+  @Test
   public void groupRefsByMessage() throws Exception {
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
@@ -175,7 +195,7 @@
   public void readOnlyProjectRejectedBeforeTestingPermissions() throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       try (ProjectConfigUpdate u = updateProject(project)) {
-        u.getConfig().getProject().setState(ProjectState.READ_ONLY);
+        u.getConfig().updateProject(p -> p.setState(ProjectState.READ_ONLY));
         u.save();
       }
     }
@@ -362,22 +382,28 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    cfg.getAccessSections().stream()
-        .filter(
-            s ->
-                s.getName().startsWith("refs/heads/")
-                    || s.getName().startsWith("refs/for/")
-                    || s.getName().equals("refs/*"))
-        .forEach(s -> Arrays.stream(permissions).forEach(s::removePermission));
+    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+      if (s.getName().startsWith("refs/heads/")
+          || s.getName().startsWith("refs/for/")
+          || s.getName().equals("refs/*")) {
+        cfg.upsertAccessSection(
+            s.getName(),
+            updatedSection -> {
+              Arrays.stream(permissions).forEach(p -> updatedSection.remove(Permission.builder(p)));
+            });
+      }
+    }
   }
 
   private static void removeAllGlobalCapabilities(ProjectConfig cfg, String... capabilities) {
     Arrays.stream(capabilities)
         .forEach(
             c ->
-                cfg.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-                    .getPermission(c, true)
-                    .clearRules());
+                cfg.upsertAccessSection(
+                    AccessSection.GLOBAL_CAPABILITIES,
+                    as -> {
+                      as.upsertPermission(c).clearRules();
+                    }));
   }
 
   private PushResult push(String... refSpecs) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index a96fbcf..dd689e6 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -35,12 +35,12 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -116,7 +116,7 @@
   @Before
   public void setUp() throws Exception {
     admins = adminGroupUuid();
-    nonInteractiveUsers = groupUuid("Non-Interactive Users");
+    nonInteractiveUsers = groupUuid("Service Users");
     setUpPermissions();
     setUpChanges();
   }
@@ -125,8 +125,14 @@
   private void setUpPermissions() throws Exception {
     // Remove read permissions for all users besides admin.
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (AccessSection sec : u.getConfig().getAccessSections()) {
-        sec.removePermission(Permission.READ);
+
+      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+        u.getConfig()
+            .upsertAccessSection(
+                sec.getName(),
+                updatedSec -> {
+                  updatedSec.removePermission(Permission.READ);
+                });
       }
       u.save();
     }
@@ -137,8 +143,13 @@
 
     // Remove all read permissions on All-Users.
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
-      for (AccessSection sec : u.getConfig().getAccessSections()) {
-        sec.removePermission(Permission.READ);
+      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+        u.getConfig()
+            .upsertAccessSection(
+                sec.getName(),
+                updatedSec -> {
+                  updatedSec.removePermission(Permission.READ);
+                });
       }
       u.save();
     }
diff --git a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
index d7952e4..9c5afd2 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -31,7 +31,7 @@
 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.entities.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.events.RefReceivedEvent;
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 0efc4f9..1c8ca93 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -24,6 +24,12 @@
 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.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -47,6 +53,7 @@
   }
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
   @Test
   @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
@@ -631,6 +638,124 @@
     expectToHaveSubmoduleState(superRepo, "master", subKey, badId);
   }
 
+  @Test
+  public void blockSubmissionForChangesModifyingSpecifiedSubmodule() throws Exception {
+    ObjectId commitId = getCommitWithSubmoduleUpdate();
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = "branch";
+    cherryPickInput.allowConflicts = true;
+
+    // The rule will fail if the next change has a submodule file modification with subKey.
+    modifySubmitRulesToBlockSubmoduleChanges(String.format("file('%s','M','SUBMODULE')", subKey));
+
+    // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
+    ChangeApi changeApi =
+        gApi.projects().name(superKey.get()).commit(commitId.getName()).cherryPick(cherryPickInput);
+
+    // Add another file to this change for good measure.
+    PushOneCommit.Result result =
+        amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+
+    assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+    assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
+  }
+
+  @Test
+  public void blockSubmissionWithSubmodules() throws Exception {
+    ObjectId commitId = getCommitWithSubmoduleUpdate();
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = "branch";
+    cherryPickInput.allowConflicts = true;
+
+    // The rule will fail if the next change has any submodule file.
+    modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+
+    // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
+    ChangeApi changeApi =
+        gApi.projects().name(superKey.get()).commit(commitId.getName()).cherryPick(cherryPickInput);
+
+    // Add another file to this change for good measure.
+    PushOneCommit.Result result =
+        amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+
+    assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+    assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
+  }
+
+  @Test
+  public void doNotBlockSubmissionWithoutSubmodules() throws Exception {
+    modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+
+    PushOneCommit.Result result =
+        createChange(superRepo, "refs/heads/master", "subject", "newFile", "content", null);
+
+    assertThat(getStatus(result.getChange())).isEqualTo("OK");
+    assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isTrue();
+  }
+
+  private ObjectId getCommitWithSubmoduleUpdate() throws Exception {
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/*", superKey, "refs/heads/*");
+    // Create branch "branch" for the parent and the submodule
+    pushChangeTo(superRepo, "branch");
+    pushChangeTo(subRepo, "branch");
+
+    // Make the superRepo a parent repo of the subRepo, for both branches.
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
+    createSubmoduleSubscription(superRepo, "branch", subKey, "branch");
+    pushChangeTo(subRepo, "master");
+    pushChangeTo(subRepo, "branch");
+
+    // This push creates a new commit in subRepo, master branch, which makes superRepo update their
+    // submodule.
+    pushChangeTo(subRepo, "master");
+
+    // Fetch the commit from superRepo that Gerrit created automatically to fulfill the submodule
+    // subscription.
+    return superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/" + "master")
+        .getObjectId();
+  }
+
+  private void modifySubmitRulesToBlockSubmoduleChanges(String filePrologQuery) throws Exception {
+    String newContent =
+        String.format(
+            "submit_rule(submit(R)) :-\n"
+                + "  gerrit:includes_file(%s),\n"
+                + "  !,\n"
+                + "  R = label('All-Submodules-Resolved', need(_)).\n"
+                + "submit_rule(submit(label('All-Submodules-Resolved', ok(A)))) :-\n"
+                + "  gerrit:commit_author(A).",
+            filePrologQuery);
+
+    try (Repository repo = repoManager.openRepository(superKey);
+        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .author(admin.newIdent())
+          .committer(admin.newIdent())
+          .add("rules.pl", newContent)
+          .message("Modify rules.pl")
+          .create();
+    }
+    projectCache.evict(superKey);
+  }
+
+  private String getStatus(ChangeData cd) throws Exception {
+
+    try (AutoCloseable changeIndex = disableChangeIndex()) {
+      try (AutoCloseable accountIndex = disableAccountIndex()) {
+        SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+        return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
+      }
+    }
+  }
+
   private ObjectId directUpdateRef(Project.NameKey project, String ref) throws Exception {
     try (Repository serverRepo = repoManager.openRepository(project);
         TestRepository<Repository> tr = new TestRepository<>(serverRepo)) {
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 0715b7e..8367f60 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 57b93f6..64bd25c 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -17,6 +17,7 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/server/schema",
+        "//lib/errorprone:annotations",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index f23cc10..8480a6d 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -26,7 +26,7 @@
 
   @ConfigSuite.Default
   public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_8);
+    return getConfig(ElasticVersion.V7_16);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
index 4caee64..4db0177 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.MustBeClosed;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.StandaloneSiteTest;
 import com.google.gerrit.entities.Project;
@@ -82,27 +83,33 @@
 
   private void setProjectsIndexLastModifiedInThePast(Path indexDir, Instant time)
       throws IOException {
-    for (Path path : getAllProjectsIndexFiles(indexDir).collect(Collectors.toList())) {
-      FS.DETECTED.setLastModified(path, time);
+    try (Stream<Path> allprojectsIndexFiles = getAllProjectsIndexFiles(indexDir)) {
+      for (Path path : allprojectsIndexFiles.collect(Collectors.toList())) {
+        FS.DETECTED.setLastModified(path, time);
+      }
     }
   }
 
   private Optional<Instant> getProjectsIndexLastModified(Path indexDir) throws IOException {
-    return getAllProjectsIndexFiles(indexDir)
-        .map(FS.DETECTED::lastModifiedInstant)
-        .max(Comparator.comparingLong(Instant::toEpochMilli));
+    try (Stream<Path> allprojectsIndexFiles = getAllProjectsIndexFiles(indexDir)) {
+      return allprojectsIndexFiles
+          .map(FS.DETECTED::lastModifiedInstant)
+          .max(Comparator.comparingLong(Instant::toEpochMilli));
+    }
   }
 
+  @MustBeClosed
   private Stream<Path> getAllProjectsIndexFiles(Path indexDir) throws IOException {
-    Optional<Path> projectsPath =
-        Files.walk(indexDir, 1)
-            .filter(Files::isDirectory)
-            .filter(p -> p.getFileName().toString().startsWith("projects_"))
-            .findFirst();
-    if (!projectsPath.isPresent()) {
-      return Stream.empty();
+    try (Stream<Path> stream = Files.walk(indexDir, 1)) {
+      Optional<Path> projectsPath =
+          stream
+              .filter(Files::isDirectory)
+              .filter(p -> p.getFileName().toString().startsWith("projects_"))
+              .findFirst();
+      if (!projectsPath.isPresent()) {
+        return Stream.empty();
+      }
+      return Files.walk(projectsPath.get(), 1, FileVisitOption.FOLLOW_LINKS);
     }
-
-    return Files.walk(projectsPath.get(), 1, FileVisitOption.FOLLOW_LINKS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
new file mode 100644
index 0000000..23d7658
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.schema.NoteDbSchemaVersion;
+import com.google.gerrit.server.schema.Schema_184;
+import com.google.gerrit.testing.TestUpdateUI;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class Schema_184IT extends AbstractDaemonTest {
+  private static final AccountGroup.NameKey SERVICE_USERS = AccountGroup.nameKey("Service Users");
+  private static final AccountGroup.NameKey NON_INTERACTIVE_USERS =
+      AccountGroup.nameKey("Non-Interactive Users");
+
+  @Inject private GroupOperations groupOperations;
+  @Inject private NoteDbSchemaVersion.Arguments args;
+
+  @Test
+  public void groupGetsRenamed() throws Exception {
+    groupOperations
+        .group(groupCache.get(SERVICE_USERS).get().getGroupUUID())
+        .forUpdate()
+        .name(NON_INTERACTIVE_USERS.get())
+        .update();
+    assertThat(hasGroup(NON_INTERACTIVE_USERS)).isTrue();
+
+    Schema_184 upgrade = new Schema_184();
+    upgrade.upgrade(args, new TestUpdateUI());
+    assertThat(hasGroup(SERVICE_USERS)).isTrue();
+    assertThat(hasGroup(NON_INTERACTIVE_USERS)).isFalse();
+  }
+
+  @Test
+  public void upgradeIsIdempotent() throws Exception {
+    groupOperations
+        .group(groupCache.get(SERVICE_USERS).get().getGroupUUID())
+        .forUpdate()
+        .name(NON_INTERACTIVE_USERS.get())
+        .update();
+    Schema_184 upgrade = new Schema_184();
+    upgrade.upgrade(args, new TestUpdateUI());
+    upgrade.upgrade(args, new TestUpdateUI());
+    assertThat(hasGroup(SERVICE_USERS)).isTrue();
+    assertThat(hasGroup(NON_INTERACTIVE_USERS)).isFalse();
+  }
+
+  private boolean hasGroup(AccountGroup.NameKey key) {
+    // We have to evict here because the schema migration doesn't have the cache available.
+    // That's OK because that also means it won't cache an old state in production.
+    groupCache.evict(key);
+    return groupCache.get(key).isPresent();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java
index af947f8..0780832 100644
--- a/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.rest.CreateTestPlugin.Input;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
 import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 09680fb..f5d9e3a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -31,7 +31,7 @@
 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.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.httpd.restapi.ParameterParser;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index c0feda9..0b0f2ec 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.rest.account;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Iterables;
@@ -35,6 +36,12 @@
     assertThat(accountInfo.displayName).isEqualTo(testAccount.displayName());
     assertThat(accountInfo.email).isEqualTo(testAccount.email());
     assertThat(accountInfo.inactive).isNull();
+    if (testAccount.tags().isEmpty()) {
+      assertThat(accountInfo.tags).isNull();
+    } else {
+      assertThat(accountInfo.tags.stream().map(Enum::name).collect(toImmutableList()))
+          .containsExactlyElementsIn(testAccount.tags());
+    }
   }
 
   /**
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 53e871f..ac82a78 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -41,8 +41,8 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index 9202f42..a5cf3e1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -19,11 +19,19 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.inject.Inject;
 import org.junit.Test;
 
 public class GetAccountDetailIT extends AbstractDaemonTest {
+  @Inject private GroupOperations groupOperations;
+  @Inject private AccountOperations accountOperations;
+
   @Test
   public void getDetail() throws Exception {
     RestResponse r = adminRestSession.get("/accounts/" + admin.username() + "/detail/");
@@ -32,4 +40,17 @@
     Account account = getAccount(admin.id());
     assertThat(info.registeredOn).isEqualTo(account.registeredOn());
   }
+
+  @Test
+  public void getDetailForServiceUser() throws Exception {
+    Account.Id serviceUser = accountOperations.newAccount().create();
+    groupOperations
+        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .forUpdate()
+        .addMember(serviceUser)
+        .update();
+    RestResponse r = adminRestSession.get("/accounts/" + serviceUser.get() + "/detail/");
+    AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
+    assertThat(info.tags).containsExactly(AccountInfo.Tag.SERVICE_USER);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index 782638a..2e2f5d9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -67,6 +67,14 @@
     assertThat(accountInfo.inactive).isTrue();
   }
 
+  @Test
+  public void getServiceUserAccount() throws Exception {
+    TestAccount serviceUser =
+        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+    assertThat(serviceUser.tags()).containsExactly("SERVICE_USER");
+    testGetAccount(serviceUser.id().toString(), serviceUser);
+  }
+
   private void testGetAccount(String id, TestAccount expectedAccount) throws Exception {
     assertAccountInfo(expectedAccount, gApi.accounts().id(id).get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index a3c0295..5c596dc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -37,13 +37,13 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -174,7 +174,7 @@
   public void voteOnBehalfOfLabelNotPermitted() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType verified = TestLabels.verified();
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
 
@@ -225,7 +225,8 @@
     assertThat(psa.realAccountId()).isEqualTo(admin.id());
 
     ChangeData cd = r.getChange();
-    Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(cd.notes()));
+    HumanComment c =
+        Iterables.getOnlyElement(commentsUtil.publishedHumanCommentsByChange(cd.notes()));
     assertThat(c.message).isEqualTo(ci.message);
     assertThat(c.author.getId()).isEqualTo(user.id());
     assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id());
@@ -505,7 +506,8 @@
     assertThat(m.author._accountId).isEqualTo(user.id().get());
 
     CommentInfo c =
-        Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).comments().get(di.path));
+        Iterables.getOnlyElement(
+            gApi.changes().id(r.getChangeId()).commentsRequest().get().get(di.path));
     assertThat(c.author._accountId).isEqualTo(user.id().get());
     assertThat(c.message).isEqualTo(di.message);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 574e919..2e702c10 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -163,6 +163,8 @@
           RestCall.put("/changes/%s/revisions/%s/drafts"),
           RestCall.get("/changes/%s/revisions/%s/comments"),
           RestCall.get("/changes/%s/revisions/%s/robotcomments"),
+          RestCall.get("/changes/%s/revisions/%s/ported_comments"),
+          RestCall.get("/changes/%s/revisions/%s/ported_drafts"),
           RestCall.builder(GET, "/changes/%s/revisions/%s/fixes")
               // GET /changes/<change>/revisions/<revision>/fixes is not implemented
               .expectedResponseCode(SC_NOT_FOUND)
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
index 90b4f01..23a1d23 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
@@ -195,7 +195,7 @@
 
   private String createRobotComment(Change.Id changeId) throws Exception {
     testCommentHelper.addRobotComment(
-        changeId.toString(), TestCommentHelper.createRobotCommentInput(PushOneCommit.FILE_NAME));
+        changeId, TestCommentHelper.createRobotCommentInput(PushOneCommit.FILE_NAME));
     return Iterables.getOnlyElement(
             Iterables.getOnlyElement(
                 gApi.changes().id(changeId.get()).current().robotComments().values()))
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index 55eeaf4..f1c0110 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -29,8 +29,8 @@
 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.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -70,6 +70,7 @@
           RestCall.get("/projects/%s/statistics.git"),
           RestCall.post("/projects/%s/index"),
           RestCall.post("/projects/%s/gc"),
+          RestCall.post("/projects/%s/create.change"),
           RestCall.get("/projects/%s/children"),
           RestCall.get("/projects/%s/branches"),
           RestCall.post("/projects/%s/branches:delete"),
@@ -277,7 +278,7 @@
     }
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getProject().setLocalDefaultDashboard(dashboardRef + ":overview");
+      u.getConfig().updateProject(p -> p.setLocalDefaultDashboard(dashboardRef + ":overview"));
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 72db9b3..faef5aa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -55,15 +55,18 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
 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.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -105,6 +108,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -299,6 +303,89 @@
     assertTrees(project, actual);
   }
 
+  /**
+   * Tests the following situation:
+   *
+   * <ul>
+   *   <li>1. create a change series, consisting out of a merge commit and a normal commit
+   *   <li>2. before submitting the change series, another non-conflicting change gets submitted
+   *   <li>3. when the change series gets submitted, Gerrit must perform a merge/rebase/cherry-pick
+   * </ul>
+   */
+  @Test
+  public void submitChangeSeriesWithMergeCommitThatIsBasedOnOldTip() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+
+    // create a commit which will become the first parent of a merge commit
+    PushOneCommit.Result parent1 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 2",
+                ImmutableMap.of("foo", "foo-2", "bar", "bar-2"))
+            .to("refs/heads/master");
+
+    // reset the testRepo in order to create a sibling of parent1
+    testRepo.reset(initialHead);
+
+    // create a stable branch that we can merge back into master later
+    BranchInput in = new BranchInput();
+    in.revision = initialHead.getName();
+    gApi.projects().name(project.get()).branch("refs/heads/stable").create(in);
+
+    // create one commit in the stable branch, which will become the second parent of the merge
+    // commit
+    PushOneCommit.Result parent2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 1",
+                ImmutableMap.of("foo", "foo-1", "bar", "bar-1"))
+            .to("refs/heads/stable");
+
+    // create a merge change that merges the stable branch back into master
+    testRepo.reset(parent1.getCommit());
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
+    PushOneCommit.Result mergeChange = m.to("refs/for/master");
+    mergeChange.assertOkStatus();
+
+    // approve the merge change so that it becomes submittable
+    approve(mergeChange.getChangeId());
+
+    // create a successor change that depends on the merge change
+    PushOneCommit.Result successorChange = createChange("refs/for/master");
+
+    // simulate another developer submitting a change in the meantime (non-conflicting sibling
+    // commit of the merge commit), this means when the change series gets submitted Gerrit must
+    // perform a merge/rebase/cherry-pick now
+    testRepo.reset(parent1.getCommit());
+    submit(createChange("Other Change", "x.txt", "x content").getChangeId());
+
+    // submit the change series
+    if (getSubmitType() != SubmitType.FAST_FORWARD_ONLY) {
+      submit(successorChange.getChangeId());
+    } else {
+      submitWithConflict(
+          successorChange.getChangeId(),
+          "Failed to submit 2 changes due to the following problems:\n"
+              + "Change "
+              + mergeChange.getChange().getId()
+              + ": Project policy "
+              + "requires all submissions to be a fast-forward. Please "
+              + "rebase the change locally and upload again for review.\n"
+              + "Change "
+              + successorChange.getChange().getId()
+              + ": Project policy "
+              + "requires all submissions to be a fast-forward. Please "
+              + "rebase the change locally and upload again for review.");
+    }
+  }
+
   @Test
   public void submitNoPermission() throws Throwable {
     // create project where submit is blocked
@@ -1254,6 +1341,36 @@
     }
   }
 
+  @Test
+  public void submitThatAddsUsersAsReviewersEnsuresTheyAreNotAddedToAttentionSet()
+      throws Exception {
+    PushOneCommit.Result r = createChange("refs/heads/master", "file1", "content");
+
+    // Someone else approves, because if admin reviews, they will be added to the reviewers (and the
+    // bug won't be reproduced).
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+    change(r).current().review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    change(r).attention(admin.email()).remove(new AttentionSetInput("remove"));
+    change(r).current().submit();
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("remove");
+  }
+
+  private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
+      PushOneCommit.Result r, TestAccount account) {
+    return r.getChange().attentionSet().stream()
+        .filter(a -> a.account().get() == account.id().get())
+        .collect(Collectors.toList());
+  }
+
   private void assertSubmitter(PushOneCommit.Result change) throws Throwable {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
@@ -1261,11 +1378,11 @@
     assertThat(messages).hasSize(3);
     String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      assertThat(last).startsWith("Change has been successfully cherry-picked as ");
+      assertThat(last).startsWith("Change has been successfully cherry-picked as");
     } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       assertThat(last).startsWith("Change has been successfully rebased and submitted as");
     } else {
-      assertThat(last).isEqualTo("Change has been successfully merged by Administrator");
+      assertThat(last).isEqualTo("Change has been successfully merged");
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index fff67f3..955dd7a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 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.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 2d47dd8..36cd3cb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.acceptance.UseClockStep;
 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.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.client.ReviewerState;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index caa8832..5c50949 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,25 +15,68 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+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.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+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.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
-import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.FakeEmailSender;
+import com.google.gerrit.testing.TestCommentHelper;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
+import java.util.stream.Collectors;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 @UseClockStep(clockStepUnit = TimeUnit.MINUTES)
 public class AttentionSetIT extends AbstractDaemonTest {
+
+  @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Inject private FakeEmailSender email;
+  @Inject private TestCommentHelper testCommentHelper;
+  @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+
   /** Simulates a fake clock. Uses second granularity. */
   private static class FakeClock implements LongSupplier {
     Instant now = Instant.now();
@@ -68,8 +111,9 @@
   @Test
   public void addUser() throws Exception {
     PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
     int accountId =
-        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "first"))._accountId;
+        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "first"))._accountId;
     assertThat(accountId).isEqualTo(user.id().get());
     AttentionSetUpdate expectedAttentionSetUpdate =
         AttentionSetUpdate.createFromRead(
@@ -78,9 +122,16 @@
 
     // Second add is ignored.
     accountId =
-        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "second"))._accountId;
+        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "second"))._accountId;
     assertThat(accountId).isEqualTo(user.id().get());
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Only one email since the second add was ignored.
+    String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+    assertThat(emailBody)
+        .contains(
+            user.fullName()
+                + " added themselves to the attention set of this change.\n The reason is: first.");
   }
 
   @Test
@@ -88,13 +139,13 @@
     PushOneCommit.Result r = createChange();
     Instant timestamp1 = fakeClock.now();
     int accountId1 =
-        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "user"))._accountId;
+        change(r).addToAttentionSet(new AttentionSetInput(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"))
+            .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "admin"))
             ._accountId;
     assertThat(accountId2).isEqualTo(admin.id().get());
 
@@ -111,9 +162,11 @@
   @Test
   public void removeUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "added"));
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "added"));
+    requestScopeOperations.setApiUser(user.id());
+
     fakeClock.advance(Duration.ofSeconds(42));
-    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
     AttentionSetUpdate expectedAttentionSetUpdate =
         AttentionSetUpdate.createFromRead(
             fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed");
@@ -121,16 +174,1353 @@
 
     // Second removal is ignored.
     fakeClock.advance(Duration.ofSeconds(42));
-    change(r)
-        .attention(user.id().toString())
-        .remove(new RemoveFromAttentionSetInput("removed again"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed again"));
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Only one email since the second remove was ignored.
+    String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+    assertThat(emailBody)
+        .contains(
+            user.fullName()
+                + " removed themselves from the attention set of this change.\n"
+                + " The reason is: removed.");
+  }
+
+  @Test
+  public void removeUserWithInvalidUserInput() throws Exception {
+    PushOneCommit.Result r = createChange();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                change(r)
+                    .attention(user.id().toString())
+                    .remove(new AttentionSetInput("invalid user", "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo("The user specified in the input body couldn't be found.");
+
+    exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                change(r)
+                    .attention(user.id().toString())
+                    .remove(new AttentionSetInput(admin.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "The field \"user\" must be empty, or must match the user specified in the URL.");
   }
 
   @Test
   public void removeUnrelatedUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("foo"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("foo"));
     assertThat(r.getChange().attentionSet()).isEmpty();
   }
+
+  @Test
+  public void abandonRemovesUsers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "admin"));
+
+    change(r).abandon();
+
+    AttentionSetUpdate userUpdate =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(userUpdate).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(userUpdate).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(userUpdate).hasReasonThat().isEqualTo("Change was abandoned");
+
+    AttentionSetUpdate adminUpdate =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(adminUpdate).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(adminUpdate).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(adminUpdate).hasReasonThat().isEqualTo("Change was abandoned");
+  }
+
+  @Test
+  public void workInProgressRemovesUsers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+
+    change(r).setWorkInProgress();
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was marked work in progress");
+  }
+
+  @Test
+  public void submitRemovesUsersForAllSubmittedChanges() throws Exception {
+    PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
+
+    change(r1)
+        .current()
+        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    PushOneCommit.Result r2 = createChange("refs/heads/master", "file2", "content");
+    change(r2)
+        .current()
+        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+
+    change(r2).current().submit();
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r1, user));
+
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r2, user));
+
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
+  }
+
+  @Test
+  public void robotSubmitsRemovesUsers() throws Exception {
+    PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
+
+    change(r1)
+        .current()
+        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+    requestScopeOperations.setApiUser(robot.id());
+    change(r1).current().submit();
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r1, user));
+
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
+  }
+
+  @Test
+  public void addedReviewersAreAddedToAttentionSetOnMergedChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).current().review(ReviewInput.approve());
+    change(r).current().submit();
+
+    change(r).addReviewer(user.email());
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void reviewersAddedAndRemovedFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.id().toString());
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
+
+    change(r).reviewer(user.email()).remove();
+
+    attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
+  }
+
+  @Test
+  public void removedCcRemovedFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add cc
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email();
+    input.state = ReviewerState.CC;
+    change(r).addReviewer(input);
+
+    // Add them to the attention set
+    AttentionSetInput attentionSetInput = new AttentionSetInput();
+    attentionSetInput.user = user.email();
+    attentionSetInput.reason = "reason";
+    change(r).addToAttentionSet(attentionSetInput);
+
+    // Remove them from cc
+    change(r).reviewer(user.email()).remove();
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
+  }
+
+  @Test
+  public void reviewersAddedAndRemovedByEmailFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.email());
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
+
+    change(r).reviewer(user.email()).remove();
+
+    attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
+  }
+
+  @Test
+  public void reviewersInWorkProgressNotAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void addingReviewerWhileMarkingWorkInProgressDoesntAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = ReviewInput.create().setWorkInProgress(true);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.REVIEWER;
+    addReviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+
+    change(r).current().review(reviewInput);
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void reviewersAddedAsReviewersAgainAreNotAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.id().toString());
+    change(r)
+        .attention(user.id().toString())
+        .remove(new AttentionSetInput("removed and not re-added when re-adding as reviewer"));
+
+    change(r).addReviewer(user.id().toString());
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("removed and not re-added when re-adding as reviewer");
+  }
+
+  @Test
+  public void ccsAreIgnored() throws Exception {
+    PushOneCommit.Result r = createChange();
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.CC;
+    addReviewerInput.reviewer = user.email();
+
+    change(r).addReviewer(addReviewerInput);
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void ccsConsideredSameAsRemovedForExistingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.CC;
+    addReviewerInput.reviewer = user.email();
+    change(r).addReviewer(addReviewerInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
+  }
+
+  @Test
+  public void robotReadyForReviewAddsAllReviewersToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    TestAccount robot =
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).setReadyForReview();
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was marked ready for review");
+  }
+
+  @Test
+  public void readyForReviewAddsAllReviewersToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    change(r).setReadyForReview();
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was marked ready for review");
+  }
+
+  @Test
+  public void readyForReviewWhileRemovingReviewerRemovesThemToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    ReviewInput reviewInput = ReviewInput.create().setReady(true);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.CC;
+    addReviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
+  }
+
+  @Test
+  public void readyForReviewWhileAddingReviewerAddsThemToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
+    ReviewInput reviewInput = ReviewInput.create().setReady(true).reviewer(user.email());
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+
+    HashtagsInput hashtagsInput = new HashtagsInput();
+    hashtagsInput.add = ImmutableSet.of("tag");
+    change(r).setHashtags(hashtagsInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("removed");
+  }
+
+  @Test
+  public void reviewAddsManuallyAddedUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "reason");
+
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+
+    // No emails for adding to attention set were sent.
+    email.getMessages().isEmpty();
+  }
+
+  @Test
+  public void reviewRemovesManuallyRemovedUserFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput reviewInput =
+        ReviewInput.create().removeUserFromAttentionSet(user.email(), "reason");
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+
+    // No emails for removing from attention set were sent.
+    email.getMessages().isEmpty();
+  }
+
+  @Test
+  public void reviewWithManualAdditionToAttentionSetFailsWithoutReason() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+
+    ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage()).isEqualTo("missing field: reason");
+  }
+
+  @Test
+  public void reviewWithManualAdditionToAttentionSetFailsWithoutUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet("", "reason");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage()).isEqualTo("missing field: user");
+  }
+
+  @Test
+  public void reviewAddReviewerWhileRemovingFromAttentionSetJustRemovesUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "addition"));
+
+    ReviewInput reviewInput =
+        ReviewInput.create()
+            .reviewer(user.email())
+            .removeUserFromAttentionSet(user.email(), "reason");
+
+    change(r).current().review(reviewInput);
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+  }
+
+  @Test
+  public void cantAddAndRemoveSameUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput =
+        ReviewInput.create()
+            .removeUserFromAttentionSet(user.email(), "reason")
+            .addUserToAttentionSet(user.username(), "reason");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "user can not be added/removed twice, and can not be added and removed at the same"
+                + " time");
+  }
+
+  @Test
+  public void cantRemoveSameUserTwice() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput =
+        ReviewInput.create()
+            .removeUserFromAttentionSet(user.email(), "reason1")
+            .removeUserFromAttentionSet(user.username(), "reason2");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "user can not be added/removed twice, and can not be added and removed at the same"
+                + " time");
+  }
+
+  @Test
+  public void reviewDoesNotAddReviewerWithoutAutomaticRules() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = ReviewInput.recommend().blockAutomaticAttentionSetRules();
+
+    change(r).current().review(reviewInput);
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void reviewDoesNotAddReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = ReviewInput.recommend();
+
+    change(r).current().review(reviewInput);
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void cantAddSameUserTwice() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput =
+        ReviewInput.create()
+            .addUserToAttentionSet(user.email(), "reason1")
+            .addUserToAttentionSet(user.username(), "reason2");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "user can not be added/removed twice, and can not be added and removed at the same"
+                + " time");
+  }
+
+  @Test
+  public void reviewRemoveFromAttentionSetWhileMarkingReadyForReviewJustRemovesUser()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+
+    ReviewInput reviewInput =
+        ReviewInput.create().setReady(true).removeUserFromAttentionSet(user.email(), "reason");
+
+    change(r).current().review(reviewInput);
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+  }
+
+  @Test
+  public void reviewAddToAttentionSetWhileMarkingWorkInProgressJustAddsUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+
+    ReviewInput reviewInput =
+        ReviewInput.create().setWorkInProgress(true).addUserToAttentionSet(user.email(), "reason");
+
+    change(r).attention(user.email()).remove(new AttentionSetInput("removal"));
+    change(r).current().review(reviewInput);
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+  }
+
+  @Test
+  public void reviewRemovesUserFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("removed on reply");
+  }
+
+  @Test
+  public void reviewAddUserToAttentionSetWhileReplyingJustAddsUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(admin.email(), "reason");
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+  }
+
+  @Test
+  public void reviewWhileAddingThemselvesAsReviewerStillRemovesThem() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    ReviewInput reviewInput = ReviewInput.create().reviewer(user.email());
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("removed on reply");
+  }
+
+  @Test
+  public void reviewWhileAddingThemselvesAsReviewerDoesNotAddThem() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput reviewInput = ReviewInput.create().reviewer(user.email());
+    change(r).current().review(reviewInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void repliesAddsOwner() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void repliesDoNotAddOwnerWhenChangeIsClosed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).abandon();
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
+  public void repliesDoNotAddOwnerWhenChangeIsWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
+  public void repliesDoNotAddOwnerWhenChangeIsBecomingWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+
+    ReviewInput reviewInput = ReviewInput.create().setWorkInProgress(true);
+    change(r).current().review(reviewInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
+  public void repliesAddOwnerWhenChangeIsBecomingReadyForReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+
+    ReviewInput reviewInput = ReviewInput.create().setReady(true);
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void repliesAddsOwnerAndUploader() throws Exception {
+    // Create change with owner: admin
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+
+    change(r).attention(user.email()).remove(new AttentionSetInput("reason"));
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    // Uploader added
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+
+    // Owner added
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void reviewIgnoresRobotCommentsForAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    testCommentHelper.addRobotComment(
+        r.getChangeId(),
+        TestCommentHelper.createRobotCommentInputWithMandatoryFields(Patch.COMMIT_MSG));
+
+    requestScopeOperations.setApiUser(admin.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(r.getChangeId()).current().robotCommentsAsList())
+                    .id));
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void reviewAddsAllUsersInCommentThread() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    change(r).current().review(reviewWithComment());
+
+    TestAccount user2 = accountCreator.user2();
+
+    requestScopeOperations.setApiUser(user2.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(r.getChangeId()).current().commentsAsList())
+                    .id));
+
+    change(r).attention(user.email()).remove(new AttentionSetInput("removal"));
+    requestScopeOperations.setApiUser(admin.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                gApi.changes().id(r.getChangeId()).current().commentsAsList().get(1).id));
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Someone else replied on a comment you posted");
+
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user2));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user2.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Someone else replied on a comment you posted");
+  }
+
+  @Test
+  public void reviewAddsAllUsersInCommentThreadWhenOriginalCommentIsARobotComment()
+      throws Exception {
+    PushOneCommit.Result result = createChange();
+    testCommentHelper.addRobotComment(
+        result.getChangeId(),
+        TestCommentHelper.createRobotCommentInputWithMandatoryFields(Patch.COMMIT_MSG));
+
+    requestScopeOperations.setApiUser(user.id());
+    // Reply to the robot comment.
+    change(result)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(result.getChangeId()).current().robotCommentsAsList())
+                    .id));
+
+    requestScopeOperations.setApiUser(admin.id());
+    // Reply to the human comment. which was a reply to the robot comment.
+    change(result)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(result.getChangeId()).current().commentsAsList())
+                    .id));
+
+    // The user which replied to the robot comment was added to the attention set.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(result, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Someone else replied on a comment you posted");
+  }
+
+  @Test
+  public void reviewAddsAllUsersInCommentThreadEvenOfDifferentChildBranch() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+    Account.Id user1 = accountOperations.newAccount().create();
+    Account.Id user2 = accountOperations.newAccount().create();
+    Account.Id user3 = accountOperations.newAccount().create();
+    Account.Id user4 = accountOperations.newAccount().create();
+    // Add users as reviewers.
+    gApi.changes().id(changeId.get()).addReviewer(user1.toString());
+    gApi.changes().id(changeId.get()).addReviewer(user2.toString());
+    gApi.changes().id(changeId.get()).addReviewer(user3.toString());
+    gApi.changes().id(changeId.get()).addReviewer(user4.toString());
+    // Add a comment thread with branches. Such threads occur if people reply in parallel without
+    // having seen/loaded the reply of another person.
+    String root =
+        changeOperations.change(changeId).currentPatchset().newComment().author(user1).create();
+    String sibling1 =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .author(user2)
+            .parentUuid(root)
+            .create();
+    String sibling2 =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .author(user3)
+            .parentUuid(root)
+            .create();
+    changeOperations
+        .change(changeId)
+        .currentPatchset()
+        .newComment()
+        .author(user4)
+        .parentUuid(sibling2)
+        .create();
+    // Clear the attention set. Necessary as we used Gerrit APIs above which affect the attention
+    // set.
+    AttentionSetInput clearAttention = new AttentionSetInput("clear attention set");
+    gApi.changes().id(changeId.get()).attention(user1.toString()).remove(clearAttention);
+    gApi.changes().id(changeId.get()).attention(user2.toString()).remove(clearAttention);
+    gApi.changes().id(changeId.get()).attention(user3.toString()).remove(clearAttention);
+    gApi.changes().id(changeId.get()).attention(user4.toString()).remove(clearAttention);
+
+    requestScopeOperations.setApiUser(changeOwner);
+    // Simulate that this reply is a child of sibling1 and thus parallel to sibling2 and its child.
+    gApi.changes().id(changeId.get()).current().review(reviewInReplyToComment(sibling1));
+
+    List<AttentionSetUpdate> attentionSetUpdates = getAttentionSetUpdates(changeId);
+    assertThat(attentionSetUpdates)
+        .comparingElementsUsing(hasAccount())
+        .containsExactly(user1, user2, user3, user4);
+  }
+
+  @Test
+  public void reviewAddsAllUsersInCommentThreadWhenPostedAsDraft() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    change(r).current().review(reviewWithComment());
+
+    requestScopeOperations.setApiUser(admin.id());
+    testCommentHelper.addDraft(
+        r.getChangeId(),
+        testCommentHelper.newDraft(
+            "message",
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).current().commentsAsList())
+                .id));
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = ReviewInput.DraftHandling.PUBLISH;
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Someone else replied on a comment you posted");
+  }
+
+  @Test
+  public void reviewDoesNotAddUsersInACommentThreadThatAreNotActiveInTheChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    change(r).current().review(reviewWithComment());
+    change(r).reviewer(user.id().toString()).remove(new DeleteReviewerInput());
+
+    requestScopeOperations.setApiUser(admin.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(r.getChangeId()).current().commentsAsList())
+                    .id));
+
+    // The user was to be added, but was not added since that user is no longer a
+    // reviewer/cc/owner/uploader.
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void ownerRepliesWhileRemovingReviewerRemovesFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+
+    ReviewInput reviewInput = ReviewInput.create().reviewer(user.email(), ReviewerState.CC, false);
+    change(r).current().review(reviewInput);
+
+    // cc removed
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
+  }
+
+  @Test
+  public void uploaderRepliesAddsOwner() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+
+    // Add reviewer and cc
+    TestAccount reviewer = accountCreator.user2();
+    TestAccount cc = accountCreator.admin2();
+    ReviewInput reviewInput = new ReviewInput().blockAutomaticAttentionSetRules();
+    reviewInput = reviewInput.reviewer(reviewer.email());
+    reviewInput.reviewer(cc.email(), ReviewerState.CC, false);
+    change(r).current().review(reviewInput);
+
+    requestScopeOperations.setApiUser(user.id());
+
+    change(r).current().review(new ReviewInput());
+
+    // Reviewer and CC not added since the uploader didn't reply to their comments
+    assertThat(getAttentionSetUpdatesForUser(r, cc)).isEmpty();
+    assertThat(getAttentionSetUpdatesForUser(r, reviewer)).isEmpty();
+
+    // Owner added
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void repliesWhileAddingAsReviewerStillRemovesUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "remove"));
+
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = ReviewInput.recommend();
+    change(r).current().review(reviewInput);
+
+    // reviewer removed
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("removed on reply");
+  }
+
+  @Test
+  public void attentionSetUnchangedWithIgnoreAutomaticAttentionSetRules() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(admin.email(), ReviewerState.CC, false)
+                .blockAutomaticAttentionSetRules());
+
+    // admin is still in the attention set, although replies remove from attention set, and removing
+    // from reviewer also should remove from attention set.
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+  }
+
+  @Test
+  public void ownerNotAddedAsReviewerToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).current().review(ReviewInput.approve());
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
+  public void ownerNotAddedAsReviewerToAttentionSetWithoutAutomaticRules() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).current().review(ReviewInput.approve().blockAutomaticAttentionSetRules());
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
+  public void uploaderNotAddedAsReviewerToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChangeWithUploader(r, project, user);
+    requestScopeOperations.setApiUser(user.id());
+
+    change(r).current().review(ReviewInput.recommend());
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void uploaderNotAddedAsReviewerToAttentionSetWithoutAutomaticRules() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChangeWithUploader(r, project, user);
+    requestScopeOperations.setApiUser(user.id());
+
+    change(r).current().review(ReviewInput.recommend().blockAutomaticAttentionSetRules());
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void attentionSetStillChangesWithIgnoreAutomaticAttentionSetRulesWithInputList()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .removeUserFromAttentionSet(admin.email(), "removed")
+                .blockAutomaticAttentionSetRules());
+
+    // Admin is still removed although we block default attention set rules, since we remove
+    // the admin manually.
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("removed");
+  }
+
+  @Test
+  public void robotsNotAddedToAttentionSet() throws Exception {
+    TestAccount robot =
+        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+    PushOneCommit.Result r = createChange();
+
+    // Throw an error when adding a robot explicitly.
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).addToAttentionSet(new AttentionSetInput(robot.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "robot1@example.com is a robot, and robots can't be added to the attention set.");
+
+    // Robots are not added implicitly.
+    change(r).addReviewer(robot.email());
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void robotAddingAReviewerChangeAttentionSet() throws Exception {
+    TestAccount robot =
+        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).addReviewer(user.id().toString());
+
+    // Bots can still change the attention set, just not when replying.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void robotReviewDoesNotChangeAttentionSet() throws Exception {
+    TestAccount robot =
+        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).current().review(ReviewInput.recommend());
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void robotReviewWithNegativeLabelAddsOwner() throws Exception {
+    TestAccount robot =
+        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).current().review(ReviewInput.dislike());
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("A robot voted negatively on a label");
+  }
+
+  @Test
+  public void robotCommentDoesNotAddOwnerOnClosedChanges() throws Exception {
+    TestAccount robot =
+        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).abandon();
+
+    requestScopeOperations.setApiUser(robot.id());
+    ReviewInput reviewInput = new ReviewInput();
+    ReviewInput.RobotCommentInput robotCommentInput =
+        TestCommentHelper.createRobotCommentInputWithMandatoryFields("a.txt");
+    reviewInput.robotComments = ImmutableMap.of("a.txt", ImmutableList.of(robotCommentInput));
+    change(r).current().review(reviewInput);
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void robotCanChangeAttentionSetExplicitly() throws Exception {
+    TestAccount robot =
+        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).current().review(new ReviewInput().addUserToAttentionSet(admin.email(), "reason"));
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+  }
+
+  @Test
+  public void addUsersToAttentionSetInPrivateChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setPrivate(true);
+    change(r).current().review(new ReviewInput().addUserToAttentionSet(user.email(), "reason"));
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+  }
+
+  @Test
+  public void addUsersAsReviewerAndAttentionSetInPrivateChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setPrivate(true);
+    change(r).current().review(new ReviewInput().reviewer(user.email()));
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void attentionSetEmailFooter() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add user to attention set. They receive an email with the attention footer.
+    change(r).addReviewer(user.id().toString());
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("Gerrit-Attention: " + user.fullName());
+    sender.clear();
+
+    // Irrelevant reply, User is still in the attention set.
+    change(r).current().review(ReviewInput.approve());
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("Gerrit-Attention: " + user.fullName());
+    sender.clear();
+
+    // Abandon the change which removes user from attention set; there is an email but without the
+    // attention footer.
+    change(r).abandon();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .doesNotContain("Gerrit-Attention: " + user.fullName());
+    sender.clear();
+  }
+
+  @Test
+  @GerritConfig(name = "change.enableAttentionSet", value = "true")
+  public void attentionSetEmailHeader() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount user2 = accountCreator.user2();
+    // Add user and user2 to the attention set.
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create().reviewer(user.email()).reviewer(accountCreator.user2().email()));
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(
+            "Attention is currently required from: "
+                + user2.fullName()
+                + ", "
+                + user.fullName()
+                + ".");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .contains(
+            "Attention is currently required from: "
+                + user2.fullName()
+                + ", "
+                + user.fullName()
+                + ".");
+    sender.clear();
+
+    // Irrelevant reply, User and User2 are still in the attention set.
+    change(r).current().review(ReviewInput.approve());
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(
+            "Attention is currently required from: "
+                + user2.fullName()
+                + ", "
+                + user.fullName()
+                + ".");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .contains(
+            "Attention is currently required from: "
+                + user2.fullName()
+                + ", "
+                + user.fullName()
+                + ".");
+    sender.clear();
+
+    // Abandon the change which removes user from attention set; there is an email but without the
+    // attention footer.
+    change(r).abandon();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .doesNotContain("Attention is currently required");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .doesNotContain("Attention is currently required");
+    sender.clear();
+  }
+
+  @Test
+  @GerritConfig(name = "change.enableAttentionSet", value = "false")
+  public void noReferenceToAttentionSetInEmailsWhenDisabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // Add user and to the attention set.
+    change(r).addReviewer(user.id().toString());
+
+    // Attention set is not referenced.
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .doesNotContain("Attention is currently required");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .doesNotContain("Attention is currently required");
+    sender.clear();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilter() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
+
+    // Add user to attention set. They receive an email since they are in the attention set.
+    change(r).addReviewer(user.id().toString());
+    assertThat(sender.getMessages()).isNotEmpty();
+    sender.clear();
+
+    // Irrelevant reply, User is still in the attention set, thus got another email.
+    change(r).current().review(ReviewInput.approve());
+    assertThat(sender.getMessages()).isNotEmpty();
+    sender.clear();
+
+    // Abandon the change which removes user from attention set; the user doesn't receive an email
+    // since they are not in the attention set.
+    change(r).abandon();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
+
+    // Ensure emails that don't relate to changes are still sent.
+    gApi.accounts().id(user.id().get()).generateHttpPassword();
+    assertThat(sender.getMessages()).isNotEmpty();
+  }
+
+  @Test
+  public void outsideAttentionSet_watchProjectEmailReceived() throws Exception {
+    setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
+
+    requestScopeOperations.setApiUser(user.id());
+    watch(project.get());
+
+    createChange();
+
+    assertThat(sender.getMessages()).isNotEmpty();
+    sender.clear();
+  }
+
+  private void setEmailStrategyForUser(EmailStrategy es) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = es;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+  }
+
+  private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
+      PushOneCommit.Result r, TestAccount account) {
+    return getAttentionSetUpdates(r.getChange().getId()).stream()
+        .filter(a -> a.account().equals(account.id()))
+        .collect(Collectors.toList());
+  }
+
+  private List<AttentionSetUpdate> getAttentionSetUpdates(Change.Id changeId) {
+    List<ChangeData> changeData = changeQueryProvider.get().byLegacyChangeId(changeId);
+    if (changeData.size() != 1) {
+      throw new IllegalStateException(
+          String.format("Not exactly one change found for ID %s.", changeId));
+    }
+    return new ArrayList<>(Iterables.getOnlyElement(changeData).attentionSet());
+  }
+
+  private ReviewInput reviewWithComment() {
+    return reviewInReplyToComment(null);
+  }
+
+  private ReviewInput reviewInReplyToComment(@Nullable String id) {
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.side = Side.REVISION;
+    comment.path = Patch.COMMIT_MSG;
+    comment.message = "comment";
+    comment.updated = TimeUtil.nowTs();
+    comment.inReplyTo = id;
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
+    return reviewInput;
+  }
+
+  private Correspondence<AttentionSetUpdate, Account.Id> hasAccount() {
+    return NullAwareCorrespondence.transforming(AttentionSetUpdate::account, "hasAccount");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index 47fb20a..dd85cb0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 843ecc6..012e98d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -33,7 +34,6 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 94357b9..a6bd5eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -36,7 +36,8 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 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.Address;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
@@ -53,7 +54,6 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index 243991b..ed21050 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 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.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index a0ebf02..7fe2a50 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 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.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;
@@ -36,9 +36,9 @@
 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.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -429,7 +429,8 @@
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
       PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
+          changeNoteUtil.newAccountIdIdent(
+              getAccount(admin.id()).id(), c.created, serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index 0099fe6..058a96f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.acceptance.UseClockStep;
 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.Permission;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 61dc4d4..def4ed8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 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.AccountGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 7df4573..987d646 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -28,10 +28,10 @@
 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;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -243,7 +243,7 @@
 
     LabelType patchSetLock = TestLabels.patchSetLock();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
+      u.getConfig().upsertLabelType(patchSetLock);
       u.save();
     }
     projectOperations
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
index 7649316..17bf37e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -22,9 +22,11 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import org.junit.Test;
@@ -68,6 +70,24 @@
   }
 
   @Test
+  public void querySingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeWithSimpleAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeDetailWithSimpleAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  @Test
   public void queryChangeWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
@@ -88,6 +108,57 @@
         (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
   }
 
+  @Test
+  public void pluginDefinedQueryChangeWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))),
+        (id, opts) -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id, opts))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))),
+        (id, opts) -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeDetailWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
+        (id, opts) -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
+  }
+
+  @Test
+  public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+    getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void getChangeWithPluginDefinedException() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeWithException(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
   private String changeQueryUrl(Change.Id id) {
     return changeQueryUrl(id, ImmutableListMultimap.of());
   }
@@ -133,7 +204,8 @@
   }
 
   @Nullable
-  private static List<MyInfo> pluginInfoFromSingletonList(RestResponse res) throws Exception {
+  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(RestResponse res)
+      throws Exception {
     res.assertOK();
     List<Map<String, Object>> changeInfos =
         GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
@@ -142,10 +214,28 @@
   }
 
   @Nullable
-  private List<MyInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
+  private List<PluginDefinedInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
     res.assertOK();
     Map<String, Object> changeInfo =
         GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
     return decodeRawPluginsList(GSON, changeInfo.get("plugins"));
   }
+
+  @Nullable
+  private Map<Change.Id, List<PluginDefinedInfo>> pluginInfoMapFromChangeInfo(RestResponse res)
+      throws Exception {
+    res.assertOK();
+    Map<String, Object> changeInfo =
+        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+    return getPluginInfosFromChangeInfos(GSON, Arrays.asList(changeInfo));
+  }
+
+  @Nullable
+  private Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromChangeInfos(RestResponse res)
+      throws Exception {
+    res.assertOK();
+    List<Map<String, Object>> changeInfos =
+        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
+    return getPluginInfosFromChangeInfos(GSON, changeInfos);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PredicateIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PredicateIT.java
index 5e29538..d893dc7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PredicateIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractPredicateTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gson.reflect.TypeToken;
 import java.util.List;
 import java.util.Map;
@@ -31,7 +32,7 @@
     try (AutoCloseable ignored = installPlugin(PLUGIN_NAME, PluginModule.class)) {
       Change.Id changeId = createChange().getChange().getId();
       approve(String.valueOf(changeId.get()));
-      List<MyInfo> myInfos =
+      List<PluginDefinedInfo> myInfos =
           pluginInfoFromSingletonList(
               adminRestSession.get("/changes/?--my-plugin--sample&q=change:" + changeId.get()));
 
@@ -41,7 +42,7 @@
     }
   }
 
-  public List<MyInfo> pluginInfoFromSingletonList(RestResponse res) throws Exception {
+  public List<PluginDefinedInfo> pluginInfoFromSingletonList(RestResponse res) throws Exception {
     res.assertOK();
     List<Map<String, Object>> changeInfos =
         GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 670cff2..1912697 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index b259d90..5fe741d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
 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;
@@ -28,9 +28,9 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 551a349..ffde622 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
@@ -450,6 +450,23 @@
   }
 
   @Test
+  public void suggestNoServiceAccounts() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    String changeIdReviewed = createChangeFromApi();
+    String changeId = createChangeFromApi();
+
+    String name = name("foo");
+    TestAccount foo = accountCreator.create(name);
+    reviewChange(changeIdReviewed, foo);
+
+    assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo), ImmutableList.of());
+
+    groupOperations.group(serviceUsersUUID()).forUpdate().addMember(foo.id()).update();
+
+    assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(), ImmutableList.of());
+  }
+
+  @Test
   public void suggestNoExistingReviewers() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     String changeId = createChangeFromApi();
@@ -608,6 +625,13 @@
     return user(name, fullName, name);
   }
 
+  private AccountGroup.UUID serviceUsersUUID() {
+    return groupCache
+        .get(AccountGroup.nameKey("Service Users"))
+        .orElseThrow(() -> new IllegalStateException("unable to find 'Service Users'"))
+        .getGroupUUID();
+  }
+
   private void reviewChange(String changeId, TestAccount reviewer) throws RestApiException {
     gApi.changes().id(changeId).addReviewer(reviewer.id().toString());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
index a3c1722..614ce80 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.restapi.config.IndexChanges;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index d8132b7..9c17a5a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -32,6 +32,6 @@
     Map<String, GroupInfo> groupMap =
         newGson()
             .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
-    assertThat(groupMap.keySet()).containsExactly("Administrators", "Non-Interactive Users");
+    assertThat(groupMap.keySet()).containsExactly("Administrators", "Service Users");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index 9276b9a..531357a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 0e7e07f..908f17a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -33,10 +33,10 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.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.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
@@ -123,8 +123,11 @@
     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);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+          });
       md.getCommitBuilder().setAuthor(admin.newIdent());
       md.getCommitBuilder().setCommitter(admin.newIdent());
       md.setMessage("Add revert permission for all registered users\n");
@@ -158,15 +161,19 @@
     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);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+            grant(projectConfig, heads, Permission.REVERT, otherGroup);
+          });
       md.getCommitBuilder().setAuthor(admin.newIdent());
       md.getCommitBuilder().setCommitter(admin.newIdent());
       md.setMessage("Add revert permission for all registered users\n");
 
       projectConfig.commit(md);
     }
+    projectCache.evict(newProjectName);
     ProjectAccessInfo expected = pApi().access();
 
     grantRevertPermission.execute(newProjectName);
@@ -184,7 +191,7 @@
     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);
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
 
       Permission permission = all.getPermission(Permission.REVERT);
       assertThat(permission.getRules()).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index 54ae5af..5e1fc83 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -21,6 +21,7 @@
     ],
     deps = [
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index b01a07b..096c72b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -29,10 +29,10 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
new file mode 100644
index 0000000..0c221aa
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.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.acceptance.rest.project;
+
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.entities.RefNames.REFS_HEADS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.common.ChangeInput;
+import org.junit.Test;
+
+public class CreateChangeIT extends AbstractDaemonTest {
+
+  /**
+   * Just a basic test. The real functionality is tested by {@link
+   * com.google.gerrit.acceptance.rest.change.CreateChangeIT}.
+   */
+  @Test
+  public void basic() throws Exception {
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = "foo";
+    assertThat(gApi.projects().name(project.get()).branches().get().stream().map(i -> i.ref))
+        .doesNotContain(REFS_HEADS + branchInput.ref);
+    RestResponse r =
+        adminRestSession.put(
+            "/projects/" + project.get() + "/branches/" + branchInput.ref, branchInput);
+    r.assertCreated();
+
+    ChangeInput input = new ChangeInput();
+    input.branch = "foo";
+    input.subject = "subject";
+    RestResponse cr = adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    cr.assertCreated();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index e5587a9..94511f8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -25,8 +25,8 @@
 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.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 874f07a..10fd65f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -224,7 +224,7 @@
     Project project = projectCache.get(Project.nameKey(newProjectName)).get().getProject();
     assertProjectInfo(project, p);
     assertThat(project.getDescription()).isEqualTo(in.description);
-    assertThat(project.getConfiguredSubmitType()).isEqualTo(in.submitType);
+    assertThat(project.getSubmitType()).isEqualTo(in.submitType);
     assertThat(project.getBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS))
         .isEqualTo(in.useContributorAgreements);
     assertThat(project.getBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY))
@@ -368,8 +368,11 @@
 
   @Test
   public void createProjectWithCreateProjectCapabilityAndParentNotVisible() throws Exception {
-    Project parent = projectCache.get(allProjects).get().getProject();
-    parent.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig()
+          .updateProject(p -> p.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN));
+      u.save();
+    }
     projectOperations
         .allProjectsForUpdate()
         .add(
@@ -383,7 +386,12 @@
       ProjectInfo p = gApi.projects().create(in).get();
       assertThat(p.name).isEqualTo(in.name);
     } finally {
-      parent.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE);
+      try (ProjectConfigUpdate u = updateProject(allProjects)) {
+        u.getConfig()
+            .updateProject(
+                p -> p.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE));
+        u.save();
+      }
       projectOperations
           .allProjectsForUpdate()
           .remove(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 5636014..c98a58e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index ad90109..98fc020 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -28,7 +28,7 @@
 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.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index c916285..57c7b17 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -22,7 +22,7 @@
 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.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 9770031..7e60395 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
index 964cff7..b4b1be0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
@@ -25,14 +25,16 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -51,6 +53,7 @@
 import org.junit.Test;
 
 public class GetBranchIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
   @Inject private GroupOperations groupOperations;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -112,7 +115,7 @@
   @Test
   public void getChangeRef() throws Exception {
     // create a change
-    Change.Id changeId = createChange("refs/for/master").getPatchSetId().changeId();
+    Change.Id changeId = changeOperations.newChange().project(project).create();
 
     // a user without the 'Access Database' capability can see the change ref
     requestScopeOperations.setApiUser(user.id());
@@ -124,7 +127,7 @@
   public void getChangeRefOfNonVisibleChange() throws Exception {
     // create a change
     String branchName = "master";
-    Change.Id changeId = createChange("refs/for/" + branchName).getPatchSetId().changeId();
+    Change.Id changeId = changeOperations.newChange().project(project).branch(branchName).create();
 
     // block read access to the branch
     projectOperations
@@ -147,7 +150,7 @@
     TestAccount user2 = accountCreator.user2();
 
     // create a change
-    Change.Id changeId = createChange("refs/for/master").getPatchSetId().changeId();
+    Change.Id changeId = changeOperations.newChange().project(project).create();
 
     // create a change edit by 'user'
     requestScopeOperations.setApiUser(user.id());
@@ -170,7 +173,7 @@
   public void cannotGetChangeEditRefOfNonVisibleChange() throws Exception {
     // create a change
     String branchName = "master";
-    Change.Id changeId = createChange("refs/for/" + branchName).getPatchSetId().changeId();
+    Change.Id changeId = changeOperations.newChange().project(project).branch(branchName).create();
 
     // create a change edit by 'user'
     requestScopeOperations.setApiUser(user.id());
@@ -194,7 +197,7 @@
   @Test
   public void getChangeMetaRef() throws Exception {
     // create a change
-    Change.Id changeId = createChange("refs/for/master").getPatchSetId().changeId();
+    Change.Id changeId = changeOperations.newChange().project(project).create();
 
     // A user without the 'Access Database' capability can see the change meta ref.
     // This may be surprising, as 'Access Database' guards access to meta refs and the change meta
@@ -491,6 +494,71 @@
     testGetRefWithAccessDatabase(allProjects, RefNames.REFS_VERSION);
   }
 
+  @Test
+  public void cannotGetAutoMergeRef() throws Exception {
+    String file = "foo/a.txt";
+
+    // Create a base change.
+    Change.Id baseChange =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .file(file)
+            .content("base content")
+            .create();
+    approve(Integer.toString(baseChange.get()));
+    gApi.changes().id(baseChange.get()).current().submit();
+
+    // Create another branch
+    String branchName = "foo";
+    createBranchWithRevision(
+        BranchNameKey.create(project, branchName),
+        projectOperations.project(project).getHead("master").name());
+
+    // Create a change in master that touches the file.
+    Change.Id changeInMaster =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .file(file)
+            .content("master content")
+            .create();
+    approve(Integer.toString(changeInMaster.get()));
+    gApi.changes().id(changeInMaster.get()).current().submit();
+
+    // Create a change in the other branch and that touches the file.
+    Change.Id changeInOtherBranch =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch(branchName)
+            .file(file)
+            .content("other content")
+            .create();
+    approve(Integer.toString(changeInOtherBranch.get()));
+    gApi.changes().id(changeInOtherBranch.get()).current().submit();
+
+    // Create a merge change with a conflict resolution for the file.
+    Change.Id mergeChange =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .mergeOfButBaseOnFirst()
+            .tipOfBranch("master")
+            .and()
+            .tipOfBranch(branchName)
+            .file(file)
+            .content("merged content")
+            .create();
+
+    String mergeRevision =
+        changeOperations.change(mergeChange).currentPatchset().get().commitId().name();
+    assertBranchNotFound(project, RefNames.refsCacheAutomerge(mergeRevision));
+  }
+
   private void testGetRefWithAccessDatabase(Project.NameKey project, String ref)
       throws RestApiException {
     projectOperations
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index b18db81..5bd0e25 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
@@ -129,7 +129,12 @@
 
   private void unblockRead() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getAccessSection("refs/*").remove(new Permission(Permission.READ));
+      u.getConfig()
+          .upsertAccessSection(
+              "refs/*",
+              as -> {
+                as.remove(Permission.builder(Permission.READ));
+              });
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index 940fae5..a2c5c64 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -21,8 +21,7 @@
 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.entities.LabelFunction;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -75,9 +74,7 @@
 
     // 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.getConfig().updateLabelType("foo", labelType -> labelType.setDefaultValue((short) 1));
       u.save();
     }
 
@@ -100,11 +97,14 @@
 
     // 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.getConfig()
+          .updateLabelType(
+              "foo",
+              labelType -> {
+                labelType.setCanOverride(false);
+                labelType.setCopyAllScoresIfNoChange(false);
+                labelType.setAllowPostSubmit(false);
+              });
       u.save();
     }
 
@@ -128,16 +128,19 @@
 
     // 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.getConfig()
+          .updateLabelType(
+              "foo",
+              labelType -> {
+                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.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 65e352b..201bb53 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 91a2c4b..f8be28b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 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.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index ef08079..d39c96e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -26,9 +26,8 @@
 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.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -88,9 +87,7 @@
 
     // 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.getConfig().updateLabelType("foo", labelType -> labelType.setDefaultValue((short) 1));
       u.save();
     }
 
@@ -119,11 +116,14 @@
 
     // 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.getConfig()
+          .updateLabelType(
+              "foo",
+              labelType -> {
+                labelType.setCanOverride(false);
+                labelType.setCopyAllScoresIfNoChange(false);
+                labelType.setAllowPostSubmit(false);
+              });
       u.save();
     }
 
@@ -150,16 +150,19 @@
 
     // 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.getConfig()
+          .updateLabelType(
+              "foo",
+              labelType -> {
+                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.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index bb08267..2e274d9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -32,7 +32,7 @@
 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.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
index e7663f7..93b1f12 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
index 9e6b051..ba52024 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
@@ -24,8 +24,8 @@
 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.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index c3891cf..5fd55ec 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -173,4 +173,16 @@
 
     assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
   }
+
+  @Test
+  public void brokenConfigDoesNotBlockPush() throws Exception {
+    String configName = "test.config";
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "Create Project Level Config", configName, "\\\\///");
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    ProjectState state = projectCache.get(project).get();
+    assertThat(state.getConfig(configName).get().toText()).isEmpty();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index b08c72b..1e8d978 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -25,9 +25,8 @@
 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.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
@@ -450,9 +449,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setCanOverride(false));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isNull();
@@ -501,9 +498,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setCopyAnyScore(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
@@ -537,9 +532,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setCopyMinScore(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
@@ -573,9 +566,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setCopyMaxScore(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
@@ -594,9 +585,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoChange(false));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
@@ -651,9 +640,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
@@ -691,9 +678,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnTrivialRebase(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
@@ -741,9 +726,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnMergeFirstParentUpdate(true));
       u.save();
     }
     assertThat(
@@ -791,9 +774,8 @@
   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.getConfig()
+          .updateLabelType("foo", lt -> lt.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNotEmpty();
@@ -812,9 +794,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setAllowPostSubmit(false));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isNull();
@@ -863,9 +843,7 @@
   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.getConfig().updateLabelType("foo", lt -> lt.setIgnoreSelfApproval(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index f5d2db4..b1879f6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -28,7 +28,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
@@ -158,6 +159,12 @@
   @Test
   public void listTagsOfNonVisibleBranch() throws Exception {
     grantTagPermissions();
+    // Allow creating a new hidden branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).group(REGISTERED_USERS).ref("refs/heads/hidden"))
+        .update();
 
     PushOneCommit push1 = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
@@ -169,7 +176,7 @@
     assertThat(result.ref).isEqualTo(R_TAGS + tag1.ref);
     assertThat(result.revision).isEqualTo(tag1.revision);
 
-    pushTo("refs/heads/hidden");
+    pushTo("refs/heads/hidden").assertOkStatus();
     PushOneCommit push2 = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r2 = push2.to("refs/heads/hidden");
     r2.assertOkStatus();
@@ -470,8 +477,12 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    cfg.getAccessSections().stream()
-        .filter(s -> s.getName().startsWith("refs/tags/"))
-        .forEach(s -> Arrays.stream(permissions).forEach(s::removePermission));
+    for (AccessSection accessSection : ImmutableList.copyOf(cfg.getAccessSections())) {
+      cfg.upsertAccessSection(
+          accessSection.getName(),
+          updatedAccessSection -> {
+            Arrays.stream(permissions).forEach(updatedAccessSection::removePermission);
+          });
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index e924143..c7beb2d 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -171,6 +171,33 @@
   }
 
   @Test
+  public void byUserNameOrFullNameOrEmailExact() throws Exception {
+    String userName = "UserFoo";
+    String fullName = "Name Foo";
+    String email = "emailfoo@something.com";
+    Account.Id id =
+        accountOperations
+            .newAccount()
+            .username(userName)
+            .fullname(fullName)
+            .preferredEmail(email)
+            .create();
+
+    // resolver returns results for exact matches
+    assertThat(resolveByExactNameOrEmail(userName)).containsExactly(id);
+    assertThat(resolveByExactNameOrEmail(fullName)).containsExactly(id);
+    assertThat(resolveByExactNameOrEmail(email)).containsExactly(id);
+
+    // resolver does not match with prefixes
+    assertThat(resolveByExactNameOrEmail("UserF")).isEmpty();
+    assertThat(resolveByExactNameOrEmail("Name F")).isEmpty();
+    assertThat(resolveByExactNameOrEmail("emailf")).isEmpty();
+
+    /* The default name/email resolver accepts prefix matches */
+    assertThat(resolveByNameOrEmail("emai")).containsExactly(id);
+  }
+
+  @Test
   public void byNameAndEmailPrefersAccountsWithMatchingFullName() throws Exception {
     String email = name("user@example.com");
     Account.Id id1 = accountOperations.newAccount().fullname("Aaa Bbb").create();
@@ -334,6 +361,11 @@
     return accountResolver.resolveByNameOrEmail(input.toString()).asIdSet();
   }
 
+  @SuppressWarnings("deprecation")
+  private ImmutableSet<Account.Id> resolveByExactNameOrEmail(Object input) throws Exception {
+    return accountResolver.resolveByExactNameOrEmail(input.toString()).asIdSet();
+  }
+
   private void setPreferredEmailBypassingUniquenessCheck(Account.Id id, String email)
       throws Exception {
     Optional<AccountState> result =
diff --git a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
new file mode 100644
index 0000000..1e33c69
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.server.account.ListGroupMembership;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.account.UniversalGroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class ServiceUserClassifierIT extends AbstractDaemonTest {
+  @Inject private GroupOperations groupOperations;
+  @Inject private ServiceUserClassifier serviceUserClassifier;
+  @Inject private UniversalGroupBackend universalGroupBackend;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void userWithoutMembershipInServiceUserIsNotAServiceUser() throws Exception {
+    TestAccount user = accountCreator.create();
+    assertThat(serviceUserClassifier.isServiceUser(user.id())).isFalse();
+  }
+
+  @Test
+  public void userWithDirectMembershipInServiceUserIsAServiceUser() throws Exception {
+    TestAccount user = accountCreator.create(null, "Service Users");
+    assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
+  }
+
+  @Test
+  public void userWithIndirectMembershipInServiceUserIsAServiceUser() throws Exception {
+    TestAccount user = accountCreator.create();
+    AccountGroup.UUID subGroupUUID =
+        groupOperations.newGroup().name("CI Service Users").addMember(user.id()).create();
+    groupOperations.group(serviceUsersUUID()).forUpdate().addSubgroup(subGroupUUID).update();
+    assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
+  }
+
+  @Test
+  public void userWithIndirectExternalMembershipInServiceUserIsAServiceUser() throws Exception {
+    TestGroupBackend testGroupBackend = new TestGroupBackend();
+    TestAccount user = accountCreator.create();
+    GroupDescription.Basic externalServiceUsers = testGroupBackend.create("External Service Users");
+
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(testGroupBackend)) {
+      assertThat(universalGroupBackend.handles(externalServiceUsers.getGroupUUID())).isTrue();
+      assertThat(serviceUserClassifier.isServiceUser(user.id())).isFalse();
+
+      groupOperations
+          .group(serviceUsersUUID())
+          .forUpdate()
+          .addSubgroup(externalServiceUsers.getGroupUUID())
+          .update();
+      testGroupBackend.setMembershipsOf(
+          user.id(),
+          new ListGroupMembership(ImmutableList.of(externalServiceUsers.getGroupUUID())));
+      assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
+    }
+  }
+
+  @Test
+  public void cyclicSubgroupsDontCauseInfiniteLoop() throws Exception {
+    TestAccount user = accountCreator.create();
+    AccountGroup.UUID subGroupUUID = groupOperations.newGroup().name("CI Service Users").create();
+    groupOperations.group(serviceUsersUUID()).forUpdate().addSubgroup(subGroupUUID).update();
+    groupOperations.group(subGroupUUID).forUpdate().addSubgroup(serviceUsersUUID()).update();
+    assertThat(serviceUserClassifier.isServiceUser(user.id())).isFalse();
+  }
+
+  private AccountGroup.UUID serviceUsersUUID() {
+    return groupCache
+        .get(AccountGroup.nameKey("Service Users"))
+        .orElseThrow(() -> new IllegalStateException("unable to find 'Service Users'"))
+        .getGroupUUID();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index ecd4025..b4dd4b3 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.acceptance.server.change;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
 
@@ -26,13 +30,20 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.TestHumanComment;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -43,12 +54,12 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 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.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;
@@ -67,9 +78,9 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.function.Function;
-import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -85,8 +96,9 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<ChangesCollection> changes;
   @Inject private Provider<PostReview> postReview;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private CommentsUtil commentsUtil;
 
   private final Integer[] lines = {0, 1};
 
@@ -176,6 +188,203 @@
   }
 
   @Test
+  public void patchsetLevelCommentCanBeAddedAndRetrieved() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addComments(changeId, ps1, comment);
+
+    Map<String, List<CommentInfo>> results = getPublishedComments(changeId, ps1);
+    assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void deletePatchsetLevelComment() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    String commentMessage = "to be deleted";
+    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, commentMessage);
+    addComments(changeId, revId, comment);
+
+    Map<String, List<CommentInfo>> results = getPublishedComments(changeId, revId);
+    CommentInfo oldComment = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL));
+
+    DeleteCommentInput input = new DeleteCommentInput("reason");
+    gApi.changes().id(changeId).revision(revId).comment(oldComment.id).delete(input);
+    CommentInfo updatedComment =
+        Iterables.getOnlyElement(getPublishedComments(changeId, revId).get(PATCHSET_LEVEL));
+
+    assertThat(updatedComment.message).doesNotContain(commentMessage);
+  }
+
+  @Test
+  public void patchsetLevelCommentEmailNotification() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addComments(changeId, ps1, comment);
+
+    String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+    assertThat(emailBody).contains("Patchset");
+    assertThat(emailBody).doesNotContain("/PATCHSET_LEVEL");
+  }
+
+  @Test
+  public void patchsetLevelCommentCantHaveLine() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    input.line = 1;
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+    assertThat(ex.getMessage()).contains("line");
+  }
+
+  @Test
+  public void patchsetLevelCommentCantHaveRange() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    input.range = createLineRange(1, 3);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelCommentCantHaveSide() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    input.side = Side.REVISION;
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+    assertThat(ex.getMessage()).contains("side");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCanBeAddedAndRetrieved() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
+    assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void deletePatchsetLevelDraft() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput draft = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment 1");
+    CommentInfo returned = addDraft(changeId, revId, draft);
+    deleteDraft(changeId, revId, returned.id);
+    Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
+    assertThat(drafts).isEmpty();
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantHaveLine() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    comment.line = 1;
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
+    assertThat(ex.getMessage()).contains("line");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantHaveRange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    comment.range = createLineRange(1, 3);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantHaveSide() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    comment.side = Side.REVISION;
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantBeUpdatedToHaveLine() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
+    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
+    update.line = 1;
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> updateDraft(changeId, revId, update, update.id));
+    assertThat(ex.getMessage()).contains("line");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantBeUpdatedToHaveRange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
+    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
+    update.range = createLineRange(1, 3);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> updateDraft(changeId, revId, update, update.id));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantBeUpdatedToHaveSide() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
+    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
+    update.side = Side.REVISION;
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> updateDraft(changeId, revId, update, update.id));
+    assertThat(ex.getMessage()).contains("side");
+  }
+
+  @Test
   public void postCommentWithReply() throws Exception {
     for (Integer line : lines) {
       String file = "file";
@@ -448,6 +657,34 @@
   }
 
   @Test
+  public void putDraft_humanInReplyTo() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().create();
+
+    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    draft.inReplyTo = parentCommentUuid;
+    String createdDraftUuid = addDraft(changeId, draft).id;
+    TestHumanComment actual =
+        changeOperations.change(changeId).draftComment(createdDraftUuid).get();
+    assertThat(actual.parentUuid()).hasValue(parentCommentUuid);
+  }
+
+  @Test
+  public void putDraft_robotInReplyTo() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentRobotCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    draft.inReplyTo = parentRobotCommentUuid;
+    String createdDraftUuid = addDraft(changeId, draft).id;
+    TestHumanComment actual =
+        changeOperations.change(changeId).draftComment(createdDraftUuid).get();
+    assertThat(actual.parentUuid()).hasValue(parentRobotCommentUuid);
+  }
+
+  @Test
   public void putDraft_idMismatch() throws Exception {
     String file = "file";
     PushOneCommit.Result r = createChange();
@@ -492,6 +729,16 @@
   }
 
   @Test
+  public void putDraft_invalidInReplyTo() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    draft.inReplyTo = "invalid";
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, draft));
+    assertThat(exception.getMessage()).contains(String.format("%s not found", draft.inReplyTo));
+  }
+
+  @Test
   public void putDraft_updatePath() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -505,22 +752,93 @@
   }
 
   @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";
+  public void putDraft_updateInvalidInReplyTo() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
+
+    DraftInput updatedDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    updatedDraftInput.inReplyTo = "invalid";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> updateDraft(changeId, updatedDraftInput, originalDraft.id));
+    assertThat(exception.getMessage()).contains(String.format("Invalid inReplyTo"));
+  }
+
+  @Test
+  public void putDraft_updateHumanInReplyTo() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().create();
+    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
+
+    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    updateDraftInput.inReplyTo = parentCommentUuid;
+    updateDraft(changeId, updateDraftInput, originalDraft.id);
+    assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
+        .hasValue(parentCommentUuid);
+  }
+
+  @Test
+  public void putDraft_updateRobotInReplyTo() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentRobotCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
+
+    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    updateDraftInput.inReplyTo = parentRobotCommentUuid;
+    updateDraft(changeId, updateDraftInput, originalDraft.id);
+    assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
+        .hasValue(parentRobotCommentUuid);
+  }
+
+  @Test
+  public void putDraft_updateTag() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
+
+    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     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);
+    updateDraftInput.tag = tag;
+    updateDraft(changeId, updateDraftInput, originalDraft.id);
+    assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().tag())
+        .hasValue(tag);
+  }
+
+  @Test
+  public void updatedDraftStillPointsToParentComment() throws Exception {
+    Account.Id accountId = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().create();
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    String parentCommentUuid =
+        changeOperations.change(changeId).patchset(patchsetId).newComment().create();
+    String draftCommentUuid =
+        changeOperations
+            .change(changeId)
+            .patchset(patchsetId)
+            .newDraftComment()
+            .parentUuid(parentCommentUuid)
+            .author(accountId)
+            .create();
+
+    // Each user can only see their own drafts.
+    requestScopeOperations.setApiUser(accountId);
+    DraftInput draftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    draftInput.message = "Another comment text.";
+    gApi.changes()
+        .id(changeId.get())
+        .revision(patchsetId.get())
+        .draft(draftCommentUuid)
+        .update(draftInput);
+
+    TestHumanComment comment =
+        changeOperations.change(changeId).draftComment(draftCommentUuid).get();
+    assertThat(comment.parentUuid()).hasValue(parentCommentUuid);
   }
 
   @Test
@@ -703,12 +1021,16 @@
     addComment(r1, "nit: trailing whitespace");
     addComment(r2, "typo: content");
 
-    Map<String, List<CommentInfo>> actual = gApi.changes().id(r2.getChangeId()).comments();
+    Map<String, List<CommentInfo>> actual =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().get();
     assertThat(actual.keySet()).containsExactly(FILE_NAME);
 
     List<CommentInfo> comments = actual.get(FILE_NAME);
     assertThat(comments).hasSize(2);
 
+    // Comment context is disabled by default
+    assertThat(comments.stream().filter(c -> c.contextLines != null)).isEmpty();
+
     CommentInfo c1 = comments.get(0);
     assertThat(c1.author._accountId).isEqualTo(user.id().get());
     assertThat(c1.patchSet).isEqualTo(1);
@@ -725,6 +1047,61 @@
   }
 
   @Test
+  public void listChangeCommentsWithContextEnabled() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    addCommentOnLine(r2, "nit: please fix", 1);
+    addCommentOnRange(r2, "looks good", commentRangeInLines(2, 5));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(2);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("nit: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+  }
+
+  private List<ContextLineInfo> contextLines(String... args) {
+    List<ContextLineInfo> result = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      int lineNbr = Integer.parseInt(args[i]);
+      String contextLine = args[i + 1];
+      ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
+      result.add(info);
+    }
+    return result;
+  }
+
+  @Test
   public void listChangeCommentsAnonymousDoesNotRequireAuth() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
@@ -736,12 +1113,12 @@
     addComment(r1, "nit: trailing whitespace");
     addComment(r2, "typo: content");
 
-    List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+    List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
     assertThat(comments.stream().map(c -> c.message).collect(toList()))
         .containsExactly("nit: trailing whitespace", "typo: content");
 
     requestScopeOperations.setApiUserAnonymous();
-    comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+    comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
     assertThat(comments.stream().map(c -> c.message).collect(toList()))
         .containsExactly("nit: trailing whitespace", "typo: content");
   }
@@ -862,12 +1239,6 @@
                 + "\n"
                 + "comments\n"
                 + "\n"
-                + url
-                + "c/"
-                + project.get()
-                + "/+/"
-                + c
-                + "/1/a.txt \n"
                 + "File a.txt:\n"
                 + "\n"
                 + url
@@ -875,7 +1246,9 @@
                 + project.get()
                 + "/+/"
                 + c
-                + "/1/a.txt@a1 \n"
+                + "/comment/"
+                + ps1List.get(0).id
+                + " \n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
                 + "\n"
@@ -885,17 +1258,13 @@
                 + project.get()
                 + "/+/"
                 + c
-                + "/1/a.txt@1 \n"
+                + "/comment/"
+                + ps1List.get(1).id
+                + " \n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
                 + "\n"
                 + "\n"
-                + url
-                + "c/"
-                + project.get()
-                + "/+/"
-                + c
-                + "/2/a.txt \n"
                 + "File a.txt:\n"
                 + "\n"
                 + url
@@ -903,7 +1272,9 @@
                 + project.get()
                 + "/+/"
                 + c
-                + "/2/a.txt@a1 \n"
+                + "/comment/"
+                + ps2List.get(0).id
+                + " \n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
                 + "\n"
@@ -913,7 +1284,9 @@
                 + project.get()
                 + "/+/"
                 + c
-                + "/2/a.txt@a2 \n"
+                + "/comment/"
+                + ps2List.get(1).id
+                + " \n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
                 + "\n"
@@ -923,7 +1296,9 @@
                 + project.get()
                 + "/+/"
                 + c
-                + "/2/a.txt@1 \n"
+                + "/comment/"
+                + ps2List.get(2).id
+                + " \n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
                 + "\n"
@@ -933,7 +1308,9 @@
                 + project.get()
                 + "/+/"
                 + c
-                + "/2/a.txt@2 \n"
+                + "/comment/"
+                + ps2List.get(3).id
+                + " \n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
                 + "\n"
@@ -969,6 +1346,51 @@
   }
 
   @Test
+  public void draftCommentsWithTagPublishPatchset() throws Exception {
+    PushOneCommit.Result result = createChange();
+
+    DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+    draft.tag = "old_tag";
+    addDraft(result.getChangeId(), result.getCommit().name(), draft);
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.tag = "new_tag";
+    reviewInput.drafts = DraftHandling.PUBLISH;
+    gApi.changes().id(result.getChangeId()).current().review(reviewInput);
+
+    assertThat(
+            Iterables.getOnlyElement(
+                    gApi.changes().id(result.getChangeId()).current().commentsAsList())
+                .tag)
+        .isEqualTo("new_tag");
+  }
+
+  @Test
+  public void draftCommentsWithTagPublishAllRevisions() throws Exception {
+    PushOneCommit.Result result = createChange();
+
+    DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+    draft.tag = "old_tag";
+    addDraft(result.getChangeId(), result.getCommit().name(), draft);
+
+    amendChange(result.getChangeId());
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.tag = "new_tag";
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    gApi.changes().id(result.getChangeId()).current().review(reviewInput);
+
+    assertThat(
+            Iterables.getOnlyElement(
+                    gApi.changes()
+                        .id(result.getChangeId())
+                        .revision(result.getCommit().name())
+                        .commentsAsList())
+                .tag)
+        .isEqualTo("new_tag");
+  }
+
+  @Test
   public void queryChangesWithCommentCount() throws Exception {
     // PS1 has three comments in three different threads, PS2 has one comment in one thread.
     PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
@@ -1083,7 +1505,7 @@
     addComments(changeId, ps4, c7, c8);
 
     // 11th commit: Add (c9) to PS2.
-    CommentInput c9 = newComment("b.txt", "comment 9");
+    CommentInput c9 = newCommentWithOnlyMandatoryFields("b.txt", "comment 9");
     addComments(changeId, ps2, c9);
 
     List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
@@ -1186,6 +1608,77 @@
     assertThat(getChangeSortedComments(id.get())).hasSize(3);
   }
 
+  @Test
+  public void canCreateHumanCommentWithRobotCommentAsParentAndUnsetUnresolved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentRobotCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    createdCommentInput.inReplyTo = parentRobotCommentUuid;
+    createdCommentInput.unresolved = null;
+    addComments(changeId, createdCommentInput);
+
+    CommentInfo resultNewComment =
+        Iterables.getOnlyElement(
+            getPublishedCommentsAsList(changeId).stream()
+                .filter(c -> c.message.equals("comment reply"))
+                .collect(toImmutableSet()));
+
+    assertThat(resultNewComment.inReplyTo).isEqualTo(parentRobotCommentUuid);
+
+    // Default unresolved is false.
+    assertThat(resultNewComment.unresolved).isFalse();
+  }
+
+  @Test
+  public void canCreateHumanCommentWithHumanCommentAsParent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().create();
+
+    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    createdCommentInput.inReplyTo = parentCommentUuid;
+    addComments(changeId, createdCommentInput);
+
+    CommentInfo resultNewComment =
+        Iterables.getOnlyElement(
+            getPublishedCommentsAsList(changeId).stream()
+                .filter(c -> c.message.equals("comment reply"))
+                .collect(toImmutableSet()));
+    assertThat(resultNewComment.inReplyTo).isEqualTo(parentCommentUuid);
+  }
+
+  @Test
+  public void canCreateHumanCommentWithRobotCommentAsParent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentRobotCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    createdCommentInput.inReplyTo = parentRobotCommentUuid;
+    addComments(changeId, createdCommentInput);
+
+    CommentInfo resultNewComment =
+        Iterables.getOnlyElement(
+            getPublishedCommentsAsList(changeId).stream()
+                .filter(c -> c.message.equals("comment reply"))
+                .collect(toImmutableSet()));
+    assertThat(resultNewComment.inReplyTo).isEqualTo(parentRobotCommentUuid);
+  }
+
+  @Test
+  public void cannotCreateCommentWithInvalidInReplyTo() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    CommentInput comment = newComment(COMMIT_MSG, "comment 1 reply");
+    comment.inReplyTo = "invalid";
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> addComments(changeId, comment));
+    assertThat(exception.getMessage()).contains(String.format("%s not found", comment.inReplyTo));
+  }
+
   private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
     return getPublishedComments(changeId, revId).values().stream()
         .flatMap(List::stream)
@@ -1200,6 +1693,12 @@
     return comment;
   }
 
+  private void addComments(Change.Id changeId, CommentInput... commentInputs) throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+    gApi.changes().id(changeId.get()).current().review(input);
+  }
+
   private void addComments(String changeId, String revision, CommentInput... commentInputs)
       throws Exception {
     ReviewInput input = new ReviewInput();
@@ -1226,16 +1725,16 @@
         RevCommit commitBefore = beforeDelete.get(i);
         RevCommit commitAfter = afterDelete.get(i);
 
-        Map<String, com.google.gerrit.entities.Comment> commentMapBefore =
+        Map<String, HumanComment> commentMapBefore =
             DeleteCommentRewriter.getPublishedComments(
                 noteUtil, reader, NoteMap.read(reader, commitBefore));
-        Map<String, com.google.gerrit.entities.Comment> commentMapAfter =
+        Map<String, HumanComment> commentMapAfter =
             DeleteCommentRewriter.getPublishedComments(
                 noteUtil, reader, NoteMap.read(reader, commitAfter));
 
         if (commentMapBefore.containsKey(targetCommentUuid)) {
           assertThat(commentMapAfter).containsKey(targetCommentUuid);
-          com.google.gerrit.entities.Comment comment = commentMapAfter.get(targetCommentUuid);
+          HumanComment comment = commentMapAfter.get(targetCommentUuid);
           assertThat(comment.message).isEqualTo(expectedMessage);
           comment.message = commentMapBefore.get(targetCommentUuid).message;
           commentMapAfter.put(targetCommentUuid, comment);
@@ -1275,7 +1774,23 @@
   }
 
   private void addComment(PushOneCommit.Result r, String message) throws Exception {
-    addComment(r, message, false, false, null);
+    addComment(r, message, false, false, null, null, null);
+  }
+
+  private void addCommentOnLine(PushOneCommit.Result r, String message, int line) throws Exception {
+    addComment(r, message, false, false, null, line, null);
+  }
+
+  private void addCommentOnRange(PushOneCommit.Result r, String message, Comment.Range range)
+      throws Exception {
+    addComment(r, message, false, false, null, null, range);
+  }
+
+  private Comment.Range commentRangeInLines(int startLine, int endLine) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.endLine = endLine;
+    return range;
   }
 
   private void addComment(
@@ -1285,12 +1800,25 @@
       Boolean unresolved,
       String inReplyTo)
       throws Exception {
+    addComment(r, message, omitDuplicateComments, unresolved, inReplyTo, null, null);
+  }
+
+  private void addComment(
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo,
+      Integer line,
+      Comment.Range range)
+      throws Exception {
     CommentInput c = new CommentInput();
-    c.line = 1;
+    c.line = line == null ? 1 : line;
     c.message = message;
     c.path = FILE_NAME;
     c.unresolved = unresolved;
     c.inReplyTo = inReplyTo;
+    c.range = range;
     ReviewInput in = newInput(c);
     in.omitDuplicateComments = omitDuplicateComments;
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
@@ -1300,11 +1828,19 @@
     return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
   }
 
+  private CommentInfo addDraft(Change.Id changeId, DraftInput in) throws Exception {
+    return gApi.changes().id(changeId.get()).current().createDraft(in).get();
+  }
+
   private void updateDraft(String changeId, String revId, DraftInput in, String uuid)
       throws Exception {
     gApi.changes().id(changeId).revision(revId).draft(uuid).update(in);
   }
 
+  private void updateDraft(Change.Id changeId, DraftInput in, String uuid) throws Exception {
+    gApi.changes().id(changeId.get()).current().draft(uuid).update(in);
+  }
+
   private void deleteDraft(String changeId, String revId, String uuid) throws Exception {
     gApi.changes().id(changeId).revision(revId).draft(uuid).delete();
   }
@@ -1320,7 +1856,11 @@
   }
 
   private List<CommentInfo> getPublishedCommentsAsList(String changeId) throws Exception {
-    return gApi.changes().id(changeId).commentsAsList();
+    return gApi.changes().id(changeId).commentsRequest().getAsList();
+  }
+
+  private List<CommentInfo> getPublishedCommentsAsList(Change.Id changeId) throws Exception {
+    return gApi.changes().id(changeId.get()).commentsRequest().getAsList();
   }
 
   private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
@@ -1340,31 +1880,48 @@
     return newComment(file, Side.REVISION, 0, message, false);
   }
 
+  private static CommentInput newCommentWithOnlyMandatoryFields(String path, String message) {
+    CommentInput c = new CommentInput();
+    c.unresolved = false;
+    return populate(c, path, null, null, null, null, message);
+  }
+
   private static CommentInput newComment(
       String path, Side side, int line, String message, Boolean unresolved) {
     CommentInput c = new CommentInput();
-    return populate(c, path, side, null, line, message, unresolved);
+    c.unresolved = unresolved;
+    return populate(c, path, side, null, line, message);
   }
 
   private static CommentInput newCommentOnParent(
       String path, int parent, int line, String message) {
     CommentInput c = new CommentInput();
-    return populate(c, path, Side.PARENT, parent, line, message, false);
+    c.unresolved = false;
+    return populate(c, path, Side.PARENT, parent, line, message);
   }
 
   private DraftInput newDraft(String path, Side side, int line, String message) {
     DraftInput d = new DraftInput();
-    return populate(d, path, side, null, line, message, false);
+    d.unresolved = false;
+    return populate(d, path, side, null, line, message);
   }
 
   private DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
     DraftInput d = new DraftInput();
-    return populate(d, path, side, null, range.startLine, range, message, false);
+    d.unresolved = false;
+    return populate(d, path, side, null, range.startLine, range, message);
   }
 
   private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
     DraftInput d = new DraftInput();
-    return populate(d, path, Side.PARENT, parent, line, message, false);
+    d.unresolved = false;
+    return populate(d, path, Side.PARENT, parent, line, message);
+  }
+
+  private DraftInput newDraftWithOnlyMandatoryFields(String path, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, null, null, null, null, message);
   }
 
   private static <C extends Comment> C populate(
@@ -1372,16 +1929,14 @@
       String path,
       Side side,
       Integer parent,
-      int line,
+      Integer line,
       Comment.Range range,
-      String message,
-      Boolean unresolved) {
+      String message) {
     c.path = path;
     c.side = side;
     c.parent = parent;
-    c.line = line != 0 ? line : null;
+    c.line = line != null && line != 0 ? line : null;
     c.message = message;
-    c.unresolved = unresolved;
     if (range != null) {
       c.range = range;
     }
@@ -1389,8 +1944,8 @@
   }
 
   private static <C extends Comment> C populate(
-      C c, String path, Side side, Integer parent, int line, String message, Boolean unresolved) {
-    return populate(c, path, side, parent, line, null, message, unresolved);
+      C c, String path, Side side, Integer parent, int line, String message) {
+    return populate(c, path, side, parent, line, null, message);
   }
 
   private static Comment.Range createLineRange(int startChar, int endChar) {
@@ -1403,20 +1958,22 @@
   }
 
   private static Function<CommentInfo, CommentInput> infoToInput(String path) {
-    return infoToInput(path, CommentInput::new);
+    return info -> {
+      CommentInput commentInput = new CommentInput();
+      commentInput.path = path;
+      commentInput.unresolved = info.unresolved;
+      copy(info, commentInput);
+      return commentInput;
+    };
   }
 
   private static Function<CommentInfo, DraftInput> infoToDraft(String path) {
-    return infoToInput(path, DraftInput::new);
-  }
-
-  private static <I extends Comment> Function<CommentInfo, I> infoToInput(
-      String path, Supplier<I> supplier) {
     return info -> {
-      I i = supplier.get();
-      i.path = path;
-      copy(info, i);
-      return i;
+      DraftInput draftInput = new DraftInput();
+      draftInput.path = path;
+      draftInput.unresolved = info.unresolved;
+      copy(info, draftInput);
+      return draftInput;
     };
   }
 
@@ -1426,7 +1983,6 @@
     to.line = from.line;
     to.message = from.message;
     to.range = from.range;
-    to.unresolved = from.unresolved;
     to.inReplyTo = from.inReplyTo;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index a0ac315..9d821b7 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -828,7 +828,7 @@
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
-        noteUtil.newIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
+        noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index b544f6e..74dfa04 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -58,7 +58,6 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -636,10 +635,8 @@
 
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
-    return Correspondence.from(
-        (relatedChangeAndCommitInfo, status) ->
-            Objects.equals(relatedChangeAndCommitInfo.status, status),
-        "has status");
+    return Correspondence.transforming(
+        relatedChangeAndCommitInfo -> relatedChangeAndCommitInfo.status, "has status");
   }
 
   private RevCommit parseBody(RevCommit c) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 8469fff..fb3259f 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -26,8 +26,9 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 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.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -61,8 +62,8 @@
 
   private void saveLabelConfig() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(label.getName(), label);
-      u.getConfig().getLabelSections().put(pLabel.getName(), pLabel);
+      u.getConfig().upsertLabelType(label);
+      u.getConfig().upsertLabelType(pLabel);
       u.save();
     }
   }
@@ -201,6 +202,25 @@
       assertThat(attr.value).isEqualTo(-1);
       assertThat(listener.getLastCommentAddedEvent().getComment())
           .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
+
+      // review with patch set level comment
+      reviewInput = new ReviewInput().patchSetLevelComment("a patch set level comment");
+      revision(r).review(reviewInput);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1:\n\n%s", "a patch set level comment"));
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "event.comment-added.publishPatchSetLevelComment", value = "false")
+  public void publishPatchSetLevelComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestListener listener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      ReviewInput reviewInput = new ReviewInput().patchSetLevelComment("a patch set level comment");
+      revision(r).review(reviewInput);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1:\n\n%s", "(1 comment)"));
     }
   }
 
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 d68cada..1a01184 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -22,20 +22,28 @@
 import static org.mockito.MockitoAnnotations.initMocks;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+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;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 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.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import java.util.List;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -47,6 +55,7 @@
 public class ReceiveCommitsCommentValidationIT extends AbstractDaemonTest {
   @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final int COMMENT_SIZE_LIMIT = 666;
 
@@ -101,6 +110,72 @@
   }
 
   @Test
+  public void emailsSentOnPublishCommentsHaveDifferentMessageIds() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    sender.clear();
+
+    String revId = result.getCommit().getName();
+    DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, comment);
+    amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+
+    List<FakeEmailSender.Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(2);
+
+    FakeEmailSender.Message newPatchsetMessage = messages.get(0);
+    assertThat(newPatchsetMessage.body()).contains("new patch set");
+    assertThat(newPatchsetMessage.headers().get("Message-ID").toString())
+        .doesNotContain("EmailReviewComments");
+
+    FakeEmailSender.Message newCommentsMessage = messages.get(1);
+    assertThat(newCommentsMessage.body()).contains("has posted comments on this change");
+    assertThat(newCommentsMessage.headers().get("Message-ID").toString())
+        .contains("EmailReviewComments");
+  }
+
+  @Test
+  public void publishCommentsAddsAllUsersInCommentThread() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String revId = result.getCommit().getName();
+
+    requestScopeOperations.setApiUser(user.id());
+    DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, comment);
+    ReviewInput reviewInput = new ReviewInput().blockAutomaticAttentionSetRules();
+    reviewInput.drafts = ReviewInput.DraftHandling.PUBLISH;
+    change(result).current().review(reviewInput);
+
+    requestScopeOperations.setApiUser(admin.id());
+    comment =
+        testCommentHelper.newDraft(
+            COMMENT_TEXT,
+            Iterables.getOnlyElement(gApi.changes().id(changeId).current().commentsAsList()).id);
+
+    testCommentHelper.addDraft(changeId, revId, comment);
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    AttentionSetUpdate attentionSetUpdate =
+        Iterables.getOnlyElement(amendResult.getChange().attentionSet());
+    assertThat(attentionSetUpdate.account()).isEqualTo(user.id());
+    assertThat(attentionSetUpdate.reason())
+        .isEqualTo("Someone else replied on a comment you posted");
+    assertThat(attentionSetUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+  }
+
+  @Test
+  public void attentionSetNotUpdatedWhenNoCommentsPublished() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    gApi.changes().id(changeId).attention(user.email()).remove(new AttentionSetInput("removed"));
+    ImmutableSet<AttentionSetUpdate> attentionSet = result.getChange().attentionSet();
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(attentionSet).isEqualTo(amendResult.getChange().attentionSet());
+  }
+
+  @Test
   public void validateComments_commentRejected() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index d74cd71..9b12f29 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -17,17 +17,17 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.ABANDONED_CHANGES;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.ALL_COMMENTS;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.NEW_CHANGES;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.NEW_PATCHSETS;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.SUBMITTED_CHANGES;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.ALL;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.NONE;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER_REVIEWERS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.ENABLED;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.ABANDONED_CHANGES;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.ALL_COMMENTS;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.NEW_CHANGES;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.NEW_PATCHSETS;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.SUBMITTED_CHANGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
@@ -37,7 +37,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -1500,7 +1500,7 @@
       throws Exception {
     for (SubmitType submitType : SubmitType.values()) {
       try (ProjectConfigUpdate u = updateProject(project)) {
-        u.getConfig().getProject().setSubmitType(submitType);
+        u.getConfig().updateProject(p -> p.setSubmitType(submitType));
         u.save();
       }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index e961c67..49b184b 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -45,11 +45,11 @@
   }
 
   @Test
-  @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+  @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
       values = {".+ser@example\\.com", "a@b\\.com"})
-  public void listFilterWhitelistDoesNotFilterListedUser() throws Exception {
+  public void listFilterAllowDoesNotFilterListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have been persisted
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -57,11 +57,11 @@
   }
 
   @Test
-  @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+  @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
       values = {".+@gerritcodereview\\.com", "a@b\\.com"})
-  public void listFilterWhitelistFiltersNotListedUser() throws Exception {
+  public void listFilterAllowFiltersNotListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have NOT been persisted
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -72,11 +72,11 @@
   }
 
   @Test
-  @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+  @GerritConfig(name = "receiveemail.filter.mode", value = "BLOCK")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
       values = {".+@gerritcodereview\\.com", "a@b\\.com"})
-  public void listFilterBlacklistDoesNotFilterNotListedUser() throws Exception {
+  public void listFilterBlockDoesNotFilterNotListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have been persisted
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -84,11 +84,11 @@
   }
 
   @Test
-  @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+  @GerritConfig(name = "receiveemail.filter.mode", value = "BLOCK")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
       values = {".+@example\\.com", "a@b\\.com"})
-  public void listFilterBlacklistFiltersListedUser() throws Exception {
+  public void listFilterBlockFiltersListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have been persisted
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index 0826c166..6dd2f32 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -22,9 +22,9 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index fc44822..5679c41 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -28,6 +28,7 @@
 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.entities.EmailHeader;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -47,6 +48,7 @@
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import java.net.URL;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Collection;
@@ -296,6 +298,10 @@
     assertNotifyTo(user);
     Message message = sender.nextMessage();
     assertThat(message.body()).contains("rejected one or more comments");
+
+    // ensure the message header contains a valid message id.
+    assertThat(((EmailHeader.String) (message.headers().get("Message-ID"))).getString())
+        .containsMatch("<someid-REJECTION-HTML@" + new URL(canonicalWebUrl.get()).getHost() + ">");
   }
 
   @Test
@@ -453,7 +459,7 @@
   private ImmutableSet<CommentInfo> getCommentsAndRobotComments(String changeId)
       throws RestApiException {
     return Streams.concat(
-            gApi.changes().id(changeId).comments().values().stream(),
+            gApi.changes().id(changeId).commentsRequest().get().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 0ae9ad2..1c916a3 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.entities.EmailHeader;
 import java.net.URI;
 import java.util.Map;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendIT.java
new file mode 100644
index 0000000..2aab159
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendIT.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.acceptance.server.permissions;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.Change;
+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.query.change.ChangeData;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+/** Asserts behavior on {@link PermissionBackend} using a fully-started Gerrit. */
+public class PermissionBackendIT extends AbstractDaemonTest {
+  @Inject PermissionBackend pb;
+  @Inject ChangeNotes.Factory changeNotesFactory;
+
+  @Test
+  public void changeDataFromIndex_canCheckReviewerState() throws Exception {
+    Change.Id changeId = createChange().getChange().getId();
+    gApi.changes().id(changeId.get()).setPrivate(true);
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    ChangeData changeData =
+        Iterables.getOnlyElement(queryProvider.get().byLegacyChangeId(changeId));
+    boolean reviewerCanSee =
+        pb.absentUser(user.id()).change(changeData).test(ChangePermission.READ);
+    assertThat(reviewerCanSee).isTrue();
+  }
+
+  @Test
+  public void changeDataFromNoteDb_canCheckReviewerState() throws Exception {
+    Change.Id changeId = createChange().getChange().getId();
+    gApi.changes().id(changeId.get()).setPrivate(true);
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    ChangeData changeData = changeDataFactory.create(notes);
+    boolean reviewerCanSee =
+        pb.absentUser(user.id()).change(changeData).test(ChangePermission.READ);
+    assertThat(reviewerCanSee).isTrue();
+  }
+
+  @Test
+  public void changeNotes_canCheckReviewerState() throws Exception {
+    Change.Id changeId = createChange().getChange().getId();
+    gApi.changes().id(changeId.get()).setPrivate(true);
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    boolean reviewerCanSee = pb.absentUser(user.id()).change(notes).test(ChangePermission.READ);
+    assertThat(reviewerCanSee).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 1d5204b..6aa5878 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -17,11 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
-import static com.google.gerrit.common.data.LabelFunction.ANY_WITH_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.MAX_NO_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.NO_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.NO_OP;
+import static com.google.gerrit.entities.LabelFunction.ANY_WITH_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.MAX_NO_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.MAX_WITH_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.NO_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.NO_OP;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
@@ -32,55 +32,57 @@
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 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.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
-import java.util.Arrays;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class CustomLabelIT extends AbstractDaemonTest {
+  private static final String LABEL_NAME = "CustomLabel";
+  private static final LabelType LABEL =
+      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+  private static final String P_LABEL_NAME = "CustomLabel2";
+  private static final LabelType P =
+      label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   @Inject private ProjectOperations projectOperations;
   @Inject private ExtensionRegistry extensionRegistry;
-
-  private final LabelType label =
-      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-
-  private final LabelType P = label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
   public void setUp() throws Exception {
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
-        .add(allowLabel(P.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .add(allowLabel(LABEL_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(P_LABEL_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
         .update();
   }
 
   @Test
   public void customLabelNoOp_NegativeVoteNotBlock() throws Exception {
-    label.setFunction(NO_OP);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(NO_OP));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -91,12 +93,11 @@
 
   @Test
   public void customLabelNoBlock_NegativeVoteNotBlock() throws Exception {
-    label.setFunction(NO_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(NO_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -107,12 +108,11 @@
 
   @Test
   public void customLabelMaxNoBlock_NegativeVoteNotBlock() throws Exception {
-    label.setFunction(MAX_NO_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(MAX_NO_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -123,16 +123,14 @@
 
   @Test
   public void customLabelMaxNoBlock_MaxVoteSubmittable() throws Exception {
-    label.setFunction(MAX_NO_BLOCK);
-    P.setFunction(NO_OP);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(MAX_NO_BLOCK), P.toBuilder().setFunction(NO_OP));
     PushOneCommit.Result r = createChange();
     assertThat(info(r.getChangeId()).submittable).isNull();
-    revision(r).review(ReviewInput.approve().label(label.getName(), 1));
+    revision(r).review(ReviewInput.approve().label(LABEL_NAME, 1));
 
     ChangeInfo c = getWithLabels(r);
     assertThat(c.submittable).isTrue();
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNotNull();
     assertThat(q.recommended).isNull();
@@ -143,12 +141,11 @@
 
   @Test
   public void customLabelAnyWithBlock_NegativeVoteBlock() throws Exception {
-    label.setFunction(ANY_WITH_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(ANY_WITH_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -170,19 +167,18 @@
   public void customLabelAnyWithBlock_Addreviewer_ZeroVote() throws Exception {
     TestListener testListener = new TestListener();
     try (Registration registration = extensionRegistry.newRegistration().add(testListener)) {
-      P.setFunction(ANY_WITH_BLOCK);
-      saveLabelConfig();
+      saveLabelConfig(P.toBuilder().setFunction(ANY_WITH_BLOCK));
       PushOneCommit.Result r = createChange();
       AddReviewerInput in = new AddReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-      ReviewInput input = new ReviewInput().label(P.getName(), 0);
+      ReviewInput input = new ReviewInput().label(P_LABEL_NAME, 0);
       input.message = "foo";
 
       revision(r).review(input);
       ChangeInfo c = getWithLabels(r);
-      LabelInfo q = c.labels.get(P.getName());
+      LabelInfo q = c.labels.get(P_LABEL_NAME);
       assertThat(q.all).hasSize(1);
       assertThat(q.approved).isNull();
       assertThat(q.recommended).isNull();
@@ -196,12 +192,11 @@
 
   @Test
   public void customLabelMaxWithBlock_NegativeVoteBlock() throws Exception {
-    label.setFunction(MAX_WITH_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(MAX_WITH_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -211,17 +206,38 @@
   }
 
   @Test
+  public void customLabelMaxWithBlock_DeletedVoteDoesNotTriggerNegativeBlock() throws Exception {
+    saveLabelConfig(P.toBuilder().setFunction(MAX_WITH_BLOCK));
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    revision(r).review(new ReviewInput().label(P_LABEL_NAME, 1));
+    requestScopeOperations.setApiUser(admin.id());
+    revision(r).review(new ReviewInput().label(P_LABEL_NAME, 1));
+
+    LabelInfo labelInfo = getWithLabels(r).labels.get(P_LABEL_NAME);
+    assertThat(labelInfo.all).hasSize(2);
+    assertThat(labelInfo.approved).isNotNull();
+    assertThat(labelInfo.blocking).isNull();
+
+    revision(r).reviewer(admin.email()).deleteVote(P_LABEL_NAME);
+    labelInfo = getWithLabels(r).labels.get(P_LABEL_NAME);
+    assertThat(labelInfo.all).hasSize(2); // 0 vote still delivered
+    assertThat(labelInfo.approved).isNotNull();
+    assertThat(labelInfo.rejected).isNull();
+    assertThat(labelInfo.blocking).isNull(); // label is not blocking the change submission
+  }
+
+  @Test
   public void customLabelMaxWithBlock_MaxVoteSubmittable() throws Exception {
-    label.setFunction(MAX_WITH_BLOCK);
-    P.setFunction(NO_OP);
-    saveLabelConfig();
+    saveLabelConfig(
+        LABEL.toBuilder().setFunction(MAX_WITH_BLOCK), P.toBuilder().setFunction(NO_OP));
     PushOneCommit.Result r = createChange();
     assertThat(info(r.getChangeId()).submittable).isNull();
-    revision(r).review(ReviewInput.approve().label(label.getName(), 1));
+    revision(r).review(ReviewInput.approve().label(LABEL_NAME, 1));
 
     ChangeInfo c = getWithLabels(r);
     assertThat(c.submittable).isTrue();
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNotNull();
     assertThat(q.recommended).isNull();
@@ -232,13 +248,12 @@
 
   @Test
   public void customLabelMaxWithBlock_MaxVoteNegativeVoteBlock() throws Exception {
-    label.setFunction(MAX_WITH_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(MAX_WITH_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), 1));
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, 1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -249,10 +264,9 @@
 
   @Test
   public void customLabel_DisallowPostSubmit() throws Exception {
-    label.setFunction(NO_OP);
-    label.setAllowPostSubmit(false);
-    P.setFunction(NO_OP);
-    saveLabelConfig();
+    saveLabelConfig(
+        LABEL.toBuilder().setFunction(NO_OP).setAllowPostSubmit(false),
+        P.toBuilder().setFunction(NO_OP));
 
     PushOneCommit.Result r = createChange();
     revision(r).review(ReviewInput.approve());
@@ -260,20 +274,20 @@
 
     ChangeInfo info = getWithLabels(r);
     assertPermitted(info, "Code-Review", 2);
-    assertPermitted(info, P.getName(), 0, 1);
-    assertPermitted(info, label.getName());
+    assertPermitted(info, P_LABEL_NAME, 0, 1);
+    assertPermitted(info, LABEL_NAME);
 
     ReviewInput postSubmitReview1 = new ReviewInput();
     postSubmitReview1.label(P.getName(), P.getMax().getValue());
     revision(r).review(postSubmitReview1);
 
     ReviewInput postSubmitReview2 = new ReviewInput();
-    postSubmitReview2.label(label.getName(), label.getMax().getValue());
+    postSubmitReview2.label(LABEL.getName(), LABEL.getMax().getValue());
     ResourceConflictException thrown =
         assertThrows(ResourceConflictException.class, () -> revision(r).review(postSubmitReview2));
     assertThat(thrown)
         .hasMessageThat()
-        .contains("Voting on labels disallowed after submit: " + label.getName());
+        .contains("Voting on labels disallowed after submit: " + LABEL_NAME);
   }
 
   @Test
@@ -331,10 +345,10 @@
 
   @Test
   public void customLabel_withBranch() throws Exception {
-    label.setRefPatterns(Arrays.asList("master"));
-    saveLabelConfig();
-    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
-    assertThat(cfg.getLabelSections().get(label.getName()).getRefPatterns()).contains("master");
+    saveLabelConfig(LABEL.toBuilder().setRefPatterns(ImmutableList.of("master")));
+    CachedProjectConfig cfg =
+        projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
+    assertThat(cfg.getLabelSections().get(LABEL_NAME).getRefPatterns()).contains("master");
   }
 
   private void assertLabelStatus(String changeId, String testLabel) throws Exception {
@@ -348,10 +362,11 @@
     assertThat(labelInfo.blocking).isNull();
   }
 
-  private void saveLabelConfig() throws Exception {
+  private void saveLabelConfig(LabelType.Builder... builders) throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(label.getName(), label);
-      u.getConfig().getLabelSections().put(P.getName(), P);
+      for (LabelType.Builder b : builders) {
+        u.getConfig().upsertLabelType(b.build());
+      }
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
new file mode 100644
index 0000000..6e67d5f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.project.ProjectCacheImpl;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.name.Named;
+import javax.inject.Inject;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.Test;
+
+public class ProjectCacheIT extends AbstractDaemonTest {
+  @Inject private PluginConfigFactory pluginConfigFactory;
+
+  @Inject
+  @Named(ProjectCacheImpl.CACHE_NAME)
+  private LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache;
+
+  @Inject private SitePaths sitePaths;
+
+  @Test
+  public void pluginConfig_cachedValueEqualsConfigValue() throws Exception {
+    GroupReference group = GroupReference.create(AccountGroup.uuid("uuid"), "local-group-name");
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin",
+              cfg -> {
+                cfg.setGroupReference("group-config-name", group);
+                cfg.setString("key", "my-plugin-value");
+              });
+      u.save();
+    }
+
+    PluginConfig pluginConfig = projectCache.get(project).get().getPluginConfig("important-plugin");
+    assertThat(pluginConfig.getString("key")).isEqualTo("my-plugin-value");
+
+    assertThat(pluginConfig.getGroupReference("group-config-name")).isPresent();
+    assertThat(pluginConfig.getGroupReference("group-config-name")).hasValue(group);
+  }
+
+  @Test
+  public void pluginConfig_inheritedCachedValueEqualsConfigValue() throws Exception {
+    GroupReference group = GroupReference.create(AccountGroup.uuid("uuid"), "local-group-name");
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin",
+              cfg -> {
+                cfg.setGroupReference("group-config-name", group);
+                cfg.setString("key", "my-plugin-value");
+              });
+      u.save();
+    }
+
+    PluginConfig pluginConfig =
+        pluginConfigFactory.getFromProjectConfigWithInheritance(project, "important-plugin");
+    assertThat(pluginConfig.getString("key")).isEqualTo("my-plugin-value");
+
+    assertThat(pluginConfig.getGroupReference("group-config-name")).isPresent();
+    assertThat(pluginConfig.getGroupReference("group-config-name")).hasValue(group);
+  }
+
+  @Test
+  public void allProjectsProjectsConfig_ChangeInFileInvalidatesPersistedCache() throws Exception {
+    assertThat(projectCache.getAllProjects().getConfig().getCheckReceivedObjects()).isTrue();
+    // Change etc/All-Projects-project.config
+    FileBasedConfig fileBasedConfig =
+        new FileBasedConfig(
+            sitePaths
+                .etc_dir
+                .resolve(allProjects.get())
+                .resolve(ProjectConfig.PROJECT_CONFIG)
+                .toFile(),
+            FS.DETECTED);
+    fileBasedConfig.setString("receive", null, "checkReceivedObjects", "false");
+    fileBasedConfig.save();
+    // Invalidate only the in-memory cache
+    inMemoryProjectCache.invalidate(allProjects);
+    assertThat(projectCache.getAllProjects().getConfig().getCheckReceivedObjects()).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 7a80cbd..33276e7 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -25,15 +25,15 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.NotifyConfig;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import java.util.EnumSet;
@@ -50,15 +50,15 @@
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("new-patch-set");
     nc.setHeader(NotifyConfig.Header.CC);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
     nc.setFilter("message:sekret");
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("watch", nc);
+      u.getConfig().putNotifyConfig("watch", nc.build());
       u.save();
     }
 
@@ -91,14 +91,14 @@
   @Test
   public void noNotificationForPrivateChangesForWatchersInNotifyConfig() throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("team");
     nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("team", nc);
+      u.getConfig().putNotifyConfig("team", nc.build());
       u.save();
     }
 
@@ -123,14 +123,14 @@
   public void noNotificationForChangeThatIsTurnedPrivateForWatchersInNotifyConfig()
       throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("team");
     nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("team", nc);
+      u.getConfig().putNotifyConfig("team", nc.build());
       u.save();
     }
 
@@ -152,14 +152,14 @@
   @Test
   public void noNotificationForWipChangesForWatchersInNotifyConfig() throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("team");
     nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("team", nc);
+      u.getConfig().putNotifyConfig("team", nc.build());
       u.save();
     }
 
@@ -183,14 +183,14 @@
   @Test
   public void noNotificationForChangeThatIsTurnedWipForWatchersInNotifyConfig() throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("team");
     nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("team", nc);
+      u.getConfig().putNotifyConfig("team", nc.build());
       u.save();
     }
 
@@ -279,7 +279,7 @@
     sender.clear();
 
     // watch project as user2
-    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2", null);
+    TestAccount user2 = accountCreator.create("user2", "user2@example.com", "User2", null);
     requestScopeOperations.setApiUser(user2.id());
     watch(watchedProject);
 
@@ -391,7 +391,7 @@
     sender.clear();
 
     // watch project as user2
-    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2", null);
+    TestAccount user2 = accountCreator.create("user2", "user2@example.com", "User2", null);
     requestScopeOperations.setApiUser(user2.id());
     watch(anyProject);
 
@@ -528,7 +528,7 @@
     // watch project as user that can view all private change
     TestAccount userThatCanViewPrivateChanges =
         accountCreator.create(
-            "user2", "user2@test.com", "User2", null, groupThatCanViewPrivateChanges.name);
+            "user2", "user2@example.com", "User2", null, groupThatCanViewPrivateChanges.name);
     requestScopeOperations.setApiUser(userThatCanViewPrivateChanges.id());
     watch(watchedProject);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
index 11d39b4..127f34b 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -25,9 +25,9 @@
 import com.google.gerrit.acceptance.UseLocalDisk;
 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.AccountGroup;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchApi;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
index 1c820af..d3b40cc 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -20,9 +20,9 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.inject.Inject;
 import java.util.Map;
@@ -89,7 +89,7 @@
       if (localLabelSections.isEmpty()) {
         localLabelSections.putAll(projectCache.getAllProjects().getConfig().getLabelSections());
       }
-      localLabelSections.get(labelName).setIgnoreSelfApproval(newState);
+      u.getConfig().updateLabelType(labelName, lt -> lt.setIgnoreSelfApproval(newState));
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
index 92cc396..6079388 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -19,8 +19,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologOptions;
 import com.google.gerrit.server.rules.PrologRuleEvaluator;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index e5432d1..5cbc767 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.acceptance.server.rules;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -29,7 +30,6 @@
 import java.util.Collection;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
 import org.junit.Test;
 
 /**
@@ -44,14 +44,6 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
-  @Before
-  public void setUp() {
-    // We don't want caches to interfere with our tests. If we didn't, the cache would take
-    // precedence over the index, which would never be called.
-    baseConfig.setString("cache", "changes", "memoryLimit", "0");
-    baseConfig.setString("cache", "projects", "memoryLimit", "0");
-  }
-
   @Test
   public void testUnresolvedCommentsCountPredicate() throws Exception {
     modifySubmitRules("gerrit:unresolved_comments_count(0)");
@@ -85,11 +77,40 @@
     assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
   }
 
+  @Test
+  public void testFileNamesPredicateWithANewFile() throws Exception {
+    modifySubmitRules("gerrit:files([file('a.txt', 'A', 'REGULAR')])");
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testFileNamesPredicateWithADeletedFile() throws Exception {
+    modifySubmitRules("gerrit:files([file('a.txt', 'D', 'REGULAR')])");
+    assertThat(statusForRuleRemoveFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
   private SubmitRecord.Status statusForRule() throws Exception {
     String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result1 =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
+    return getStatus(result1);
+  }
+
+  private SubmitRecord.Status statusForRuleRemoveFile() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    // create a.txt
+    commitBuilder().add("a.txt", "4").message("subject").create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    // This implictly removes a.txt
+    PushOneCommit.Result result =
+        pushFactory.create(user.newIdent(), testRepo).rm("refs/for/master");
+    testRepo.reset(oldHead);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status getStatus(PushOneCommit.Result result1) throws Exception {
     ChangeData cd = result1.getChange();
 
     Collection<SubmitRecord> records;
@@ -119,5 +140,6 @@
           .message("Modify rules.pl")
           .create();
     }
+    projectCache.evict(project);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index f35bcb7..e72d806 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -28,7 +28,7 @@
 
   @ConfigSuite.Default
   public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_8);
+    return getConfig(ElasticVersion.V7_16);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
index 4bf7c19..38293f9 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.server.query.change.OutputStreamQuery;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
@@ -52,12 +53,55 @@
   }
 
   @Test
+  public void querySingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
+  @Test
   public void queryChangeWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))),
         (id, opts) -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id, opts))));
   }
 
+  @Test
+  public void queryPluginDefinedAttributeChangeWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))),
+        (id, opts) -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id, opts))));
+  }
+
+  @Test
+  public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+    getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void getChangeWithPluginDefinedException() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeWithException(
+        id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
   private String changeQueryCmd(Change.Id id) {
     return changeQueryCmd(id, ImmutableListMultimap.of());
   }
@@ -72,7 +116,22 @@
   }
 
   @Nullable
-  private static List<MyInfo> pluginInfoFromSingletonList(String sshOutput) throws Exception {
+  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(String sshOutput)
+      throws Exception {
+    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
+
+    assertThat(changeAttrs).hasSize(1);
+    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+  }
+
+  @Nullable
+  private static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromList(String sshOutput)
+      throws Exception {
+    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
+    return getPluginInfosFromChangeInfos(GSON, changeAttrs);
+  }
+
+  private static List<Map<String, Object>> getChangeAttrs(String sshOutput) throws Exception {
     List<Map<String, Object>> changeAttrs = new ArrayList<>();
     for (String line : CharStreams.readLines(new StringReader(sshOutput))) {
       Map<String, Object> changeAttr =
@@ -81,8 +140,6 @@
         changeAttrs.add(changeAttr);
       }
     }
-
-    assertThat(changeAttrs).hasSize(1);
-    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+    return changeAttrs;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PredicateIT.java b/javatests/com/google/gerrit/acceptance/ssh/PredicateIT.java
index fc6100b..cedd270 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PredicateIT.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gson.reflect.TypeToken;
 import java.io.StringReader;
 import java.util.ArrayList;
@@ -41,7 +42,7 @@
           adminSshSession.exec(
               "gerrit query --format json --my-plugin--sample change:" + changeId.get());
       adminSshSession.assertSuccess();
-      List<MyInfo> myInfos = pluginInfoFromSingletonList(sshOutput);
+      List<PluginDefinedInfo> myInfos = pluginInfoFromSingletonList(sshOutput);
 
       assertThat(myInfos).hasSize(1);
       assertThat(myInfos.get(0).name).isEqualTo(PLUGIN_NAME);
@@ -49,7 +50,8 @@
     }
   }
 
-  private static List<MyInfo> pluginInfoFromSingletonList(String sshOutput) throws Exception {
+  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(String sshOutput)
+      throws Exception {
     List<Map<String, Object>> changeAttrs = new ArrayList<>();
     for (String line : CharStreams.readLines(new StringReader(sshOutput))) {
       Map<String, Object> changeAttr =
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
index 5a31bfd..58c2517 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
@@ -65,7 +65,7 @@
     session.exec(
         String.format("gerrit set-reviewers -%s %s %s", add ? "a" : "r", user.email(), id));
     session.assertSuccess();
-    ImmutableSet<Account.Id> reviewers = change.getChange().getReviewers().all();
+    ImmutableSet<Account.Id> reviewers = change.getChange().reviewers().all();
     if (add) {
       assertThat(reviewers).contains(user.id());
     } else {
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
new file mode 100644
index 0000000..e0e1880
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.ssh;
+
+import static com.google.gerrit.acceptance.WaitUtil.waitUntil;
+
+import com.google.common.base.Splitter;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import java.io.IOException;
+import java.io.Reader;
+import java.time.Duration;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+@Sandboxed
+public class StreamEventsIT extends AbstractDaemonTest {
+  private static final Duration MAX_DURATION_FOR_RECEIVING_EVENTS = Duration.ofSeconds(2);
+  private static final String TEST_REVIEW_COMMENT = "any comment";
+  private StringBuilder eventsOutput = new StringBuilder();
+  private Reader streamEventsReader;
+
+  @Before
+  public void setup() throws Exception {
+    streamEventsReader = adminSshSession.execAndReturnReader("gerrit stream-events");
+  }
+
+  @After
+  public void closeStreamEvents() throws IOException {
+    streamEventsReader.close();
+  }
+
+  @Test
+  public void commentOnChangeShowsUpInStreamEvents() throws Exception {
+    reviewChange(new ReviewInput().message(TEST_REVIEW_COMMENT));
+    waitForEvent(() -> pollEventsContaining(TEST_REVIEW_COMMENT).size() == 1);
+  }
+
+  private void waitForEvent(Supplier<Boolean> waitCondition) throws InterruptedException {
+    waitUntil(() -> waitCondition.get(), MAX_DURATION_FOR_RECEIVING_EVENTS);
+  }
+
+  private void reviewChange(ReviewInput reviewInput) throws Exception {
+    ChangeApi changeApi = gApi.changes().id(createChange().getChange().getId().get());
+    changeApi.current().review(reviewInput);
+  }
+
+  private List<String> pollEventsContaining(String reviewComment) {
+    try {
+      char[] cbuf = new char[2048];
+      while (streamEventsReader.ready()) {
+        streamEventsReader.read(cbuf);
+        eventsOutput.append(cbuf);
+      }
+      return StreamSupport.stream(
+              Splitter.on('\n').trimResults().split(eventsOutput.toString()).spliterator(), false)
+          .filter(event -> event.contains(reviewComment))
+          .collect(Collectors.toList());
+    } catch (IOException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
new file mode 100644
index 0000000..0bd6554
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -0,0 +1,1302 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.hasCommit;
+import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
+import static com.google.gerrit.extensions.restapi.testing.BinaryResultSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+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.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.inject.Inject;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ChangeOperationsImplTest extends AbstractDaemonTest {
+
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void changeCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    Change.Id numericChangeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(numericChangeId);
+    assertThat(change._number).isEqualTo(numericChangeId.get());
+    assertThat(change.changeId).isNotEmpty();
+  }
+
+  @Test
+  public void changeCanBeCreatedEvenWithRequestScopeOfArbitraryUser() throws Exception {
+    Account.Id user = accountOperations.newAccount().create();
+
+    requestScopeOperations.setApiUser(user);
+    Change.Id numericChangeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(numericChangeId);
+    assertThat(change._number).isEqualTo(numericChangeId.get());
+  }
+
+  @Test
+  public void twoChangesWithoutAnyParametersDoNotClash() {
+    Change.Id changeId1 = changeOperations.newChange().create();
+    Change.Id changeId2 = changeOperations.newChange().create();
+
+    TestChange change1 = changeOperations.change(changeId1).get();
+    TestChange change2 = changeOperations.change(changeId2).get();
+    assertThat(change1.numericChangeId()).isNotEqualTo(change2.numericChangeId());
+    assertThat(change1.changeId()).isNotEqualTo(change2.changeId());
+  }
+
+  @Test
+  public void twoSubsequentlyCreatedChangesDoNotDependOnEachOther() throws Exception {
+    Change.Id changeId1 = changeOperations.newChange().create();
+    Change.Id changeId2 = changeOperations.newChange().create();
+
+    ChangeInfo change1 = getChangeFromServer(changeId1);
+    ChangeInfo change2 = getChangeFromServer(changeId2);
+    CommitInfo currentPatchsetCommit1 = change1.revisions.get(change1.currentRevision).commit;
+    CommitInfo currentPatchsetCommit2 = change2.revisions.get(change2.currentRevision).commit;
+    assertThat(currentPatchsetCommit1)
+        .parents()
+        .comparingElementsUsing(hasCommit())
+        .doesNotContain(currentPatchsetCommit2.commit);
+    assertThat(currentPatchsetCommit2)
+        .parents()
+        .comparingElementsUsing(hasCommit())
+        .doesNotContain(currentPatchsetCommit1.commit);
+  }
+
+  @Test
+  public void createdChangeHasAtLeastOnePatchset() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThatMap(change.revisions).size().isAtLeast(1);
+  }
+
+  @Test
+  public void createdChangeIsInSpecifiedProject() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.project).isEqualTo(project.get());
+  }
+
+  @Test
+  public void changeCanBeCreatedInEmptyRepository() throws Exception {
+    Project.NameKey project = projectOperations.newProject().noEmptyCommit().create();
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.project).isEqualTo(project.get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedTargetBranch() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).branch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.branch).isEqualTo("test-branch");
+  }
+
+  @Test
+  public void createdChangeUsesTipOfTargetBranchAsParentByDefault() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).branch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedBranchTipAsParent() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .tipOfBranch("refs/heads/test-branch")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void specifiedParentBranchMayHaveShortName() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().tipOfBranch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void specifiedParentBranchMustExist() {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations.newChange().childOf().tipOfBranch("not-existing-branch").create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedChangeAsParent() throws Exception {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+
+    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parentCommitId =
+        changeOperations.change(parentChangeId).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void specifiedParentChangeMustExist() {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () -> changeOperations.newChange().childOf().change(Change.id(987654321)).create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedPatchsetAsParent() throws Exception {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+    TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
+
+    Change.Id changeId =
+        changeOperations.newChange().childOf().patchset(parentPatchset.patchsetId()).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentPatchset.commitId().name());
+  }
+
+  @Test
+  public void changeOfSpecifiedParentPatchsetMustExist() {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .childOf()
+                    .patchset(PatchSet.id(Change.id(987654321), 1))
+                    .create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void specifiedParentPatchsetMustExist() {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .childOf()
+                    .patchset(PatchSet.id(parentChangeId, 1000))
+                    .create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedCommitAsParent() throws Exception {
+    // Currently, the easiest way to create a commit is by creating another change.
+    Change.Id anotherChangeId = changeOperations.newChange().create();
+    ObjectId parentCommitId =
+        changeOperations.change(anotherChangeId).currentPatchset().get().commitId();
+
+    Change.Id changeId = changeOperations.newChange().childOf().commit(parentCommitId).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.name());
+  }
+
+  @Test
+  public void specifiedParentCommitMustExist() {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .childOf()
+                    .commit(ObjectId.fromString("0123456789012345678901234567890123456789"))
+                    .create());
+    assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+  }
+
+  @Test
+  public void createdChangeUsesSpecifiedChangesInGivenOrderAsParents() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parent1CommitId =
+        changeOperations.change(parent1ChangeId).currentPatchset().get().commitId();
+    ObjectId parent2CommitId =
+        changeOperations.change(parent2ChangeId).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(parent1CommitId.name(), parent2CommitId.name())
+        .inOrder();
+  }
+
+  @Test
+  public void createdChangeUsesMergedParentsAsBaseCommit() throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Line 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file2").content("Some other content").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Line 1");
+    BinaryResult file2Content = getFileContent(changeId, patchsetId, "file2");
+    assertThat(file2Content).asString().isEqualTo("Some other content");
+  }
+
+  @Test
+  public void mergeConflictsOfParentsAreReported() {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file1").content("Content 2").create();
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .mergeOf()
+                    .change(parent1ChangeId)
+                    .and()
+                    .change(parent2ChangeId)
+                    .create());
+
+    assertThat(exception).hasMessageThat().ignoringCase().contains("conflict");
+  }
+
+  @Test
+  public void mergeConflictsCanBeAvoidedByUsingTheFirstParentAsBase() throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file1").content("Content 2").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOfButBaseOnFirst()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Content 1");
+  }
+
+  @Test
+  public void createdChangeHasAllParentsEvenWhenBasedOnFirst() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOfButBaseOnFirst()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parent1CommitId =
+        changeOperations.change(parent1ChangeId).currentPatchset().get().commitId();
+    ObjectId parent2CommitId =
+        changeOperations.change(parent2ChangeId).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(parent1CommitId.name(), parent2CommitId.name())
+        .inOrder();
+  }
+
+  @Test
+  public void automaticMergeOfMoreThanTwoParentsIsNotPossible() {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file2").content("Content 2").create();
+    Change.Id parent3ChangeId =
+        changeOperations.newChange().file("file3").content("Content 3").create();
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .mergeOf()
+                    .change(parent1ChangeId)
+                    .followedBy()
+                    .change(parent2ChangeId)
+                    .and()
+                    .change(parent3ChangeId)
+                    .create());
+
+    assertThat(exception).hasMessageThat().ignoringCase().contains("conflict");
+  }
+
+  @Test
+  public void createdChangeCanHaveMoreThanTwoParentsWhenBasedOnFirst() throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file2").content("Content 2").create();
+    Change.Id parent3ChangeId =
+        changeOperations.newChange().file("file3").content("Content 3").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOfButBaseOnFirst()
+            .change(parent1ChangeId)
+            .followedBy()
+            .change(parent2ChangeId)
+            .and()
+            .change(parent3ChangeId)
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId parent1CommitId =
+        changeOperations.change(parent1ChangeId).currentPatchset().get().commitId();
+    ObjectId parent2CommitId =
+        changeOperations.change(parent2ChangeId).currentPatchset().get().commitId();
+    ObjectId parent3CommitId =
+        changeOperations.change(parent3ChangeId).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(parent1CommitId.name(), parent2CommitId.name(), parent3CommitId.name())
+        .inOrder();
+  }
+
+  @Test
+  public void changeBasedOnParentMayHaveAdditionalFileModifications() throws Exception {
+    Change.Id parentChangeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Content 1")
+            .file("file2")
+            .content("Content 2")
+            .create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .childOf()
+            .change(parentChangeId)
+            .file("file1")
+            .content("Different content")
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Different content");
+    BinaryResult file2Content = getFileContent(changeId, patchsetId, "file2");
+    assertThat(file2Content).asString().isEqualTo("Content 2");
+  }
+
+  @Test
+  public void changeFromMergedParentsMayHaveAdditionalFileModifications() throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId =
+        changeOperations.newChange().file("file2").content("Content 2").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .file("file1")
+            .content("Different content")
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Different content");
+    BinaryResult file2Content = getFileContent(changeId, patchsetId, "file2");
+    assertThat(file2Content).asString().isEqualTo("Content 2");
+  }
+
+  @Test
+  public void changeBasedOnFirstOfMultipleParentsMayHaveAdditionalFileModifications()
+      throws Exception {
+    Change.Id parent1ChangeId =
+        changeOperations.newChange().file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOfButBaseOnFirst()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .file("file1")
+            .content("Different content")
+            .create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Different content");
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedOwner() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+  }
+
+  @Test
+  public void changeOwnerDoesNotNeedAnyPermissionsForChangeCreation() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    // Remove any read and push permissions which might potentially exist. Without read, users
+    // shouldn't be able to do anything. The newly created project should only inherit from
+    // All-Projects.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/heads/test-branch"))
+        .remove(permissionKey(Permission.PUSH).ref("refs/heads/test-branch"))
+        .update();
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/heads/test-branch"))
+        .remove(permissionKey(Permission.PUSH).ref("refs/heads/test-branch"))
+        .update();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .owner(changeOwner)
+            .branch("test-branch")
+            .project(project)
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedCommitMessage() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nDetailed description.")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit).message().startsWith("Summary line\n\nDetailed description.");
+  }
+
+  @Test
+  public void changeCannotBeCreatedWithoutCommitMessage() {
+    assertThrows(
+        IllegalStateException.class, () -> changeOperations.newChange().commitMessage("").create());
+  }
+
+  @Test
+  public void commitMessageOfCreatedChangeAutomaticallyGetsChangeId() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nDetailed description.")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit).message().contains("Change-Id:");
+  }
+
+  @Test
+  public void changeIdSpecifiedInCommitMessageIsKeptForCreatedChange() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nChange-Id: I0123456789012345678901234567890123456789")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .message()
+        .contains("Change-Id: I0123456789012345678901234567890123456789");
+    assertThat(change.changeId).isEqualTo("I0123456789012345678901234567890123456789");
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedFiles() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1")
+            .file("path/to/file2.txt")
+            .content("Line one")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "path/to/file2.txt");
+    BinaryResult fileContent1 = gApi.changes().id(changeId.get()).current().file("file1").content();
+    assertThat(fileContent1).asString().isEqualTo("Line 1");
+    BinaryResult fileContent2 =
+        gApi.changes().id(changeId.get()).current().file("path/to/file2.txt").content();
+    assertThat(fileContent2).asString().isEqualTo("Line one");
+  }
+
+  @Test
+  public void existingChangeCanBeCheckedForExistence() {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    boolean exists = changeOperations.change(changeId).exists();
+
+    assertThat(exists).isTrue();
+  }
+
+  @Test
+  public void notExistingChangeCanBeCheckedForExistence() {
+    Change.Id changeId = Change.id(123456789);
+
+    boolean exists = changeOperations.change(changeId).exists();
+
+    assertThat(exists).isFalse();
+  }
+
+  @Test
+  public void retrievingNotExistingChangeFails() {
+    Change.Id changeId = Change.id(123456789);
+    assertThrows(IllegalStateException.class, () -> changeOperations.change(changeId).get());
+  }
+
+  @Test
+  public void numericChangeIdOfExistingChangeCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    TestChange change = changeOperations.change(changeId).get();
+    assertThat(change.numericChangeId()).isEqualTo(changeId);
+  }
+
+  @Test
+  public void changeIdOfExistingChangeCanBeRetrieved() {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nChange-Id: I0123456789012345678901234567890123456789")
+            .create();
+
+    TestChange change = changeOperations.change(changeId).get();
+    assertThat(change.changeId()).isEqualTo("I0123456789012345678901234567890123456789");
+  }
+
+  @Test
+  public void currentPatchsetOfExistingChangeCanBeRetrieved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    TestPatchset patchset = changeOperations.change(changeId).currentPatchset().get();
+
+    ChangeInfo expectedChange = getChangeFromServer(changeId);
+    String expectedCommitId = expectedChange.currentRevision;
+    int expectedPatchsetNumber = expectedChange.revisions.get(expectedCommitId)._number;
+    assertThat(patchset.commitId()).isEqualTo(ObjectId.fromString(expectedCommitId));
+    assertThat(patchset.patchsetId()).isEqualTo(PatchSet.id(changeId, expectedPatchsetNumber));
+  }
+
+  @Test
+  public void earlierPatchsetOfExistingChangeCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    PatchSet.Id earlierPatchsetId =
+        changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id currentPatchsetId = changeOperations.change(changeId).newPatchset().create();
+
+    TestPatchset earlierPatchset =
+        changeOperations.change(changeId).patchset(earlierPatchsetId).get();
+
+    assertThat(earlierPatchset.patchsetId()).isEqualTo(earlierPatchsetId);
+    assertThat(earlierPatchset.patchsetId()).isNotEqualTo(currentPatchsetId);
+  }
+
+  @Test
+  public void newPatchsetCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    ChangeInfo unmodifiedChange = getChangeFromServer(changeId);
+    int originalPatchsetCount = unmodifiedChange.revisions.size();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).newPatchset().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThatMap(change.revisions).hasSize(originalPatchsetCount + 1);
+    RevisionInfo currentRevision = change.revisions.get(change.currentRevision);
+    assertThat(currentRevision._number).isEqualTo(patchsetId.get());
+  }
+
+  @Test
+  public void newPatchsetIsCopyOfPreviousPatchsetByDefault() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).newPatchset().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo patchsetRevision = getRevision(change, patchsetId);
+    assertThat(patchsetRevision.kind).isEqualTo(ChangeKind.NO_CHANGE);
+  }
+
+  @Test
+  public void newPatchsetCanHaveUpdatedCommitMessage() throws Exception {
+    Change.Id changeId = changeOperations.newChange().commitMessage("Old message").create();
+
+    changeOperations.change(changeId).newPatchset().commitMessage("New message").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit).message().startsWith("New message");
+  }
+
+  @Test
+  public void updatedCommitMessageOfNewPatchsetAutomaticallyKeepsChangeId() throws Exception {
+    Change.Id numericChangeId = changeOperations.newChange().commitMessage("Old message").create();
+    String changeId = changeOperations.change(numericChangeId).get().changeId();
+
+    changeOperations.change(numericChangeId).newPatchset().commitMessage("New message").create();
+
+    ChangeInfo change = getChangeFromServer(numericChangeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit).message().contains("Change-Id: " + changeId);
+  }
+
+  @Test
+  public void newPatchsetCanHaveDifferentChangeIdFooter() throws Exception {
+    Change.Id numericChangeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Old message\n\nChange-Id: I1111111111111111111111111111111111111111")
+            .create();
+
+    // Specifying another change-id is not an officially supported behavior of Gerrit but we might
+    // need this for some test scenarios and hence we support it in the test API.
+    changeOperations
+        .change(numericChangeId)
+        .newPatchset()
+        .commitMessage("New message\n\nChange-Id: I0123456789012345678901234567890123456789")
+        .create();
+
+    ChangeInfo change = getChangeFromServer(numericChangeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .message()
+        .contains("Change-Id: I0123456789012345678901234567890123456789");
+    assertThat(currentPatchsetCommit)
+        .message()
+        .doesNotContain("Change-Id: I1111111111111111111111111111111111111111");
+    // Actual change-id should not have been updated.
+    String changeId = changeOperations.change(numericChangeId).get().changeId();
+    assertThat(changeId).isEqualTo("I1111111111111111111111111111111111111111");
+  }
+
+  @Test
+  public void newPatchsetCanHaveReplacedFileContent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+    PatchSet.Id patchsetId =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file1")
+            .content("Different content")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1");
+    BinaryResult fileContent = getFileContent(changeId, patchsetId, "file1");
+    assertThat(fileContent).asString().isEqualTo("Different content");
+  }
+
+  @Test
+  public void newPatchsetCanHaveAdditionalFile() throws Exception {
+    Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+    PatchSet.Id patchsetId =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file2")
+            .content("My file content")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "file2");
+    BinaryResult fileContent = getFileContent(changeId, patchsetId, "file2");
+    assertThat(fileContent).asString().isEqualTo("My file content");
+  }
+
+  @Test
+  public void newPatchsetCanHaveLessFiles() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1")
+            .file("file2")
+            .content("Line one")
+            .create();
+
+    changeOperations.change(changeId).newPatchset().file("file2").delete().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1");
+  }
+
+  @Test
+  public void newPatchsetCanHaveRenamedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1")
+            .file("file2")
+            .content("Line one")
+            .create();
+
+    PatchSet.Id patchsetId =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file2")
+            .renameTo("renamed file")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "renamed file");
+    BinaryResult fileContent = getFileContent(changeId, patchsetId, "renamed file");
+    assertThat(fileContent).asString().isEqualTo("Line one");
+  }
+
+  @Test
+  public void newPatchsetCanHaveRenamedFileWithModifiedContent() throws Exception {
+    // We need sufficient content so that the slightly modified content is considered similar enough
+    // (> 60% line similarity) for a rename.
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Some content")
+            .file("file2")
+            .content("Line 1\nLine 2\nLine 3\n")
+            .create();
+    PatchSet.Id patchset1Id =
+        changeOperations.change(changeId).currentPatchset().get().patchsetId();
+
+    PatchSet.Id patchset2Id =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file2")
+            .delete()
+            .file("renamed file")
+            .content("Line 1\nLine two\nLine 3\n")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "renamed file");
+    BinaryResult fileContent = getFileContent(changeId, patchset2Id, "renamed file");
+    assertThat(fileContent).asString().isEqualTo("Line 1\nLine two\nLine 3\n");
+    DiffInfo diff =
+        gApi.changes()
+            .id(changeId.get())
+            .revision(patchset2Id.get())
+            .file("renamed file")
+            .diffRequest()
+            .withBase(patchset1Id.getId())
+            .get();
+    assertThat(diff).changeType().isEqualTo(ChangeType.RENAMED);
+  }
+
+  @Test
+  public void newPatchsetCanHaveCopiedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Some content")
+            .file("file2")
+            .content("Line 1")
+            .create();
+    PatchSet.Id patchset1Id =
+        changeOperations.change(changeId).currentPatchset().get().patchsetId();
+
+    // Copies currently can only happen if a rename happens at the same time.
+    PatchSet.Id patchset2Id =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file2")
+            .renameTo("renamed/copied file 1")
+            .file("renamed/copied file 2")
+            .content("Line 1")
+            .create();
+
+    // We can't control which of the files Gerrit/Git considers as rename and which as copy.
+    // -> Check both for the copy.
+    DiffInfo diff1 =
+        gApi.changes()
+            .id(changeId.get())
+            .revision(patchset2Id.get())
+            .file("renamed/copied file 1")
+            .diffRequest()
+            .withBase(patchset1Id.getId())
+            .get();
+    DiffInfo diff2 =
+        gApi.changes()
+            .id(changeId.get())
+            .revision(patchset2Id.get())
+            .file("renamed/copied file 2")
+            .diffRequest()
+            .withBase(patchset1Id.getId())
+            .get();
+    assertThat(ImmutableSet.of(diff1.changeType, diff2.changeType)).contains(ChangeType.COPIED);
+  }
+
+  @Test
+  public void newPatchsetCanHaveCopiedFileWithModifiedContent() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Some content")
+            .file("file2")
+            .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+            .create();
+    PatchSet.Id patchset1Id =
+        changeOperations.change(changeId).currentPatchset().get().patchsetId();
+
+    // A copy with modified content currently can only happen if the renamed file also has slightly
+    // modified content. Modify the copy slightly more as Gerrit/Git will then select it as the
+    // copied and not renamed file.
+    PatchSet.Id patchset2Id =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file2")
+            .delete()
+            .file("renamed file")
+            .content("Line 1\nLine 1.1\nLine 2\nLine 3\nLine 4\n")
+            .file("copied file")
+            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+            .create();
+
+    DiffInfo diff =
+        gApi.changes()
+            .id(changeId.get())
+            .revision(patchset2Id.get())
+            .file("copied file")
+            .diffRequest()
+            .withBase(patchset1Id.getId())
+            .get();
+    assertThat(diff).changeType().isEqualTo(ChangeType.COPIED);
+    BinaryResult fileContent = getFileContent(changeId, patchset2Id, "copied file");
+    assertThat(fileContent)
+        .asString()
+        .isEqualTo("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n");
+  }
+
+  @Test
+  public void newPatchsetCanHaveADifferentParent() throws Exception {
+    Change.Id originalParentChange = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations.newChange().childOf().change(originalParentChange).create();
+    Change.Id newParentChange = changeOperations.newChange().create();
+
+    changeOperations.change(changeId).newPatchset().parent().change(newParentChange).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId newParentCommitId =
+        changeOperations.change(newParentChange).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(newParentCommitId.name());
+  }
+
+  @Test
+  public void newPatchsetCanHaveDifferentParents() throws Exception {
+    Change.Id originalParent1Change = changeOperations.newChange().create();
+    Change.Id originalParent2Change = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(originalParent1Change)
+            .and()
+            .change(originalParent2Change)
+            .create();
+    Change.Id newParent1Change = changeOperations.newChange().create();
+    Change.Id newParent2Change = changeOperations.newChange().create();
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .parents()
+        .change(newParent1Change)
+        .and()
+        .change(newParent2Change)
+        .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId newParent1CommitId =
+        changeOperations.change(newParent1Change).currentPatchset().get().commitId();
+    ObjectId newParent2CommitId =
+        changeOperations.change(newParent2Change).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(newParent1CommitId.name(), newParent2CommitId.name());
+  }
+
+  @Test
+  public void newPatchsetCanHaveADifferentNumberOfParents() throws Exception {
+    Change.Id originalParentChange = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations.newChange().childOf().change(originalParentChange).create();
+    Change.Id newParent1Change = changeOperations.newChange().create();
+    Change.Id newParent2Change = changeOperations.newChange().create();
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .parents()
+        .change(newParent1Change)
+        .and()
+        .change(newParent2Change)
+        .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    ObjectId newParent1CommitId =
+        changeOperations.change(newParent1Change).currentPatchset().get().commitId();
+    ObjectId newParent2CommitId =
+        changeOperations.change(newParent2Change).currentPatchset().get().commitId();
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .comparingElementsUsing(hasSha1())
+        .containsExactly(newParent1CommitId.name(), newParent2CommitId.name());
+  }
+
+  @Test
+  public void newPatchsetKeepsFileContentsWithDifferentParent() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().file("file1").content("Actual change content").create();
+    Change.Id newParentChange =
+        changeOperations.newChange().file("file1").content("Parent content").create();
+
+    changeOperations.change(changeId).newPatchset().parent().change(newParentChange).create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+    assertThat(file1Content).asString().isEqualTo("Actual change content");
+  }
+
+  @Test
+  public void publishedCommentCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String commentUuid = changeOperations.change(changeId).currentPatchset().newComment().create();
+
+    TestHumanComment comment = changeOperations.change(changeId).comment(commentUuid).get();
+
+    assertThat(comment.uuid()).isEqualTo(commentUuid);
+  }
+
+  @Test
+  public void retrievingDraftCommentAsPublishedCommentFails() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newDraftComment().create();
+
+    assertThrows(
+        Exception.class, () -> changeOperations.change(changeId).comment(commentUuid).get());
+  }
+
+  @Test
+  public void parentUuidOfPublishedCommentCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().create();
+    String childCommentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .parentUuid(parentCommentUuid)
+            .create();
+
+    TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
+
+    assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+  }
+
+  @Test
+  public void tagOfPublishedCommentCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String childCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().tag("tag").create();
+
+    TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
+
+    assertThat(comment.tag()).value().isEqualTo("tag");
+  }
+
+  @Test
+  public void unresolvedOfUnresolvedPublishedCommentCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String childCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().unresolved().create();
+
+    TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
+
+    assertThat(comment.unresolved()).isTrue();
+  }
+
+  @Test
+  public void unresolvedOfResolvedPublishedCommentCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String childCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().resolved().create();
+
+    TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
+
+    assertThat(comment.unresolved()).isFalse();
+  }
+
+  @Test
+  public void draftCommentCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String commentUuid = changeOperations.change(changeId).currentPatchset().newComment().create();
+
+    TestHumanComment comment = changeOperations.change(changeId).comment(commentUuid).get();
+
+    assertThat(comment.uuid()).isEqualTo(commentUuid);
+  }
+
+  @Test
+  public void retrievingPublishedCommentAsDraftCommentFails() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String commentUuid = changeOperations.change(changeId).currentPatchset().newComment().create();
+
+    assertThrows(
+        Exception.class, () -> changeOperations.change(changeId).draftComment(commentUuid).get());
+  }
+
+  @Test
+  public void parentUuidOfDraftCommentCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().create();
+    String childCommentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .parentUuid(parentCommentUuid)
+            .create();
+
+    TestHumanComment comment =
+        changeOperations.change(changeId).draftComment(childCommentUuid).get();
+
+    assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+  }
+
+  @Test
+  public void robotCommentCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+    TestRobotComment comment = changeOperations.change(changeId).robotComment(commentUuid).get();
+
+    assertThat(comment.uuid()).isEqualTo(commentUuid);
+  }
+
+  @Test
+  public void parentUuidOfRobotCommentCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+    String childCommentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .parentUuid(parentCommentUuid)
+            .create();
+
+    TestRobotComment comment =
+        changeOperations.change(changeId).robotComment(childCommentUuid).get();
+
+    assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+  }
+
+  private ChangeInfo getChangeFromServer(Change.Id changeId) throws RestApiException {
+    return gApi.changes().id(changeId.get()).get();
+  }
+
+  private RevisionInfo getRevision(ChangeInfo change, PatchSet.Id patchsetId) {
+    return change.revisions.values().stream()
+        .filter(revision -> revision._number == patchsetId.get())
+        .findAny()
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Change %d doesn't have specified patchset %d.",
+                        change._number, patchsetId.get())));
+  }
+
+  private BinaryResult getFileContent(Change.Id changeId, PatchSet.Id patchsetId, String filePath)
+      throws RestApiException {
+    return gApi.changes().id(changeId.get()).revision(patchsetId.get()).file(filePath).content();
+  }
+
+  private Correspondence<CommitInfo, String> hasSha1() {
+    return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasSha1");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
new file mode 100644
index 0000000..080c22c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
@@ -0,0 +1,1245 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
+import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThatList;
+
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import java.util.List;
+import org.junit.Test;
+
+public class PatchsetOperationsImplTest extends AbstractDaemonTest {
+
+  @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void commentCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid = changeOperations.change(changeId).currentPatchset().newComment().create();
+    List<CommentInfo> comments = getCommentsFromServer(changeId);
+    assertThatList(comments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+  }
+
+  @Test
+  public void commentCanBeCreatedOnOlderPatchset() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    PatchSet.Id previousPatchsetId =
+        changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    changeOperations.change(changeId).newPatchset().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).patchset(previousPatchsetId).newComment().create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).patchSet().isEqualTo(previousPatchsetId.get());
+  }
+
+  @Test
+  public void commentIsCreatedWithSpecifiedMessage() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .message("Test comment message")
+            .create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).message().isEqualTo("Test comment message");
+  }
+
+  @Test
+  public void commentCanBeCreatedWithEmptyMessage() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().noMessage().create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).message().isNull();
+  }
+
+  @Test
+  public void patchsetLevelCommentCanBeCreated() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().onPatchsetLevel().create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).path().isEqualTo(Patch.PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void fileCommentCanBeCreated() throws Exception {
+    Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .onFileLevelOf("file1")
+            .create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).path().isEqualTo("file1");
+    assertThat(comment).line().isNull();
+    assertThat(comment).range().isNull();
+  }
+
+  @Test
+  public void lineCommentCanBeCreated() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .onLine(3)
+            .ofFile("file1")
+            .create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).line().isEqualTo(3);
+    assertThat(comment).range().isNull();
+  }
+
+  @Test
+  public void rangeCommentCanBeCreated() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .fromLine(2)
+            .charOffset(4)
+            .toLine(3)
+            .charOffset(5)
+            .ofFile("file1")
+            .create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).range().startLine().isEqualTo(2);
+    assertThat(comment).range().startCharacter().isEqualTo(4);
+    assertThat(comment).range().endLine().isEqualTo(3);
+    assertThat(comment).range().endCharacter().isEqualTo(5);
+    // Line is automatically filled from specified range. It's the end line.
+    assertThat(comment).line().isEqualTo(3);
+  }
+
+  @Test
+  public void commentCanBeCreatedOnPatchsetCommit() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .onPatchsetCommit()
+            .create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    // Null is often used instead of Side.REVISION as Side.REVISION is the default.
+    assertThat(comment).side().isAnyOf(Side.REVISION, null);
+    assertThat(comment).parent().isNull();
+  }
+
+  @Test
+  public void commentCanBeCreatedOnParentCommit() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().onParentCommit().create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(1);
+  }
+
+  @Test
+  public void commentCanBeCreatedOnSecondParentCommit() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .onSecondParentCommit()
+            .create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(2);
+  }
+
+  @Test
+  public void commentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .onSecondParentCommit()
+            .create();
+
+    // We want to be able to create such invalid comments for testing purposes (e.g. testing error
+    // handling or resilience of an endpoint) and hence we need to allow such invalid comments in
+    // the test API.
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(2);
+  }
+
+  @Test
+  public void commentCanBeCreatedOnAutoMergeCommit() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .onAutoMergeCommit()
+            .create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isNull();
+  }
+
+  @Test
+  public void commentCanBeCreatedAsResolved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().resolved().create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).unresolved().isFalse();
+  }
+
+  @Test
+  public void commentCanBeCreatedAsUnresolved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().unresolved().create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).unresolved().isTrue();
+  }
+
+  @Test
+  public void replyToCommentCanBeCreated() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .parentUuid(parentCommentUuid)
+            .create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).inReplyTo().isEqualTo(parentCommentUuid);
+  }
+
+  @Test
+  public void tagCanBeAttachedToAComment() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .tag("my special tag")
+            .create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).tag().isEqualTo("my special tag");
+  }
+
+  @Test
+  public void commentIsCreatedWithSpecifiedAuthor() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    Account.Id accountId = accountOperations.newAccount().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().author(accountId).create();
+
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).author().id().isEqualTo(accountId.get());
+  }
+
+  @Test
+  public void commentIsCreatedWithSpecifiedCreationTime() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    // Don't use nanos. NoteDb supports only second precision.
+    Instant creationTime =
+        LocalDateTime.of(2020, Month.SEPTEMBER, 15, 12, 10, 43).atZone(ZoneOffset.UTC).toInstant();
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .createdOn(creationTime)
+            .create();
+
+    Timestamp creationTimestamp = Timestamp.from(creationTime);
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).updated().isEqualTo(creationTimestamp);
+  }
+
+  @Test
+  public void zoneOfCreationDateCanBeOmitted() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    // As we don't care about the exact time zone internally used as a default, do a relative test
+    // so that we don't need to assert on exact instants in time. For a relative test, we need two
+    // comments whose creation date should be exactly the specified amount apart.
+    // Don't use nanos or millis. NoteDb supports only second precision.
+    LocalDateTime creationTime1 = LocalDateTime.of(2020, Month.SEPTEMBER, 15, 12, 10, 43);
+    LocalDateTime creationTime2 = creationTime1.plusMinutes(10);
+    String commentUuid1 =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .createdOn(creationTime1)
+            .create();
+    String commentUuid2 =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .createdOn(creationTime2)
+            .create();
+
+    CommentInfo comment1 = getCommentFromServer(changeId, commentUuid1);
+    Instant comment1Creation = comment1.updated.toInstant();
+    CommentInfo comment2 = getCommentFromServer(changeId, commentUuid2);
+    Instant comment2Creation = comment2.updated.toInstant();
+    Duration commentCreationDifference = Duration.between(comment1Creation, comment2Creation);
+    assertThat(commentCreationDifference).isEqualTo(Duration.ofMinutes(10));
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newDraftComment().create();
+
+    List<CommentInfo> comments = getDraftCommentsFromServer(changeId);
+    assertThatList(comments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedOnOlderPatchset() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    PatchSet.Id previousPatchsetId =
+        changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    changeOperations.change(changeId).newPatchset().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).patchset(previousPatchsetId).newDraftComment().create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).patchSet().isEqualTo(previousPatchsetId.get());
+  }
+
+  @Test
+  public void draftCommentIsCreatedWithSpecifiedMessage() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .message("Test comment message")
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).message().isEqualTo("Test comment message");
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedWithEmptyMessage() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newDraftComment().noMessage().create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).message().isNull();
+  }
+
+  @Test
+  public void draftPatchsetLevelCommentCanBeCreated() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .onPatchsetLevel()
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).path().isEqualTo(Patch.PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void draftFileCommentCanBeCreated() throws Exception {
+    Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .onFileLevelOf("file1")
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).path().isEqualTo("file1");
+    assertThat(comment).line().isNull();
+    assertThat(comment).range().isNull();
+  }
+
+  @Test
+  public void draftLineCommentCanBeCreated() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .onLine(3)
+            .ofFile("file1")
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).line().isEqualTo(3);
+    assertThat(comment).range().isNull();
+  }
+
+  @Test
+  public void draftRangeCommentCanBeCreated() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .fromLine(2)
+            .charOffset(4)
+            .toLine(3)
+            .charOffset(5)
+            .ofFile("file1")
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).range().startLine().isEqualTo(2);
+    assertThat(comment).range().startCharacter().isEqualTo(4);
+    assertThat(comment).range().endLine().isEqualTo(3);
+    assertThat(comment).range().endCharacter().isEqualTo(5);
+    // Line is automatically filled from specified range. It's the end line.
+    assertThat(comment).line().isEqualTo(3);
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedOnPatchsetCommit() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .onPatchsetCommit()
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    // Null is often used instead of Side.REVISION as Side.REVISION is the default.
+    assertThat(comment).side().isAnyOf(Side.REVISION, null);
+    assertThat(comment).parent().isNull();
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedOnParentCommit() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .onParentCommit()
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(1);
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedOnSecondParentCommit() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .onSecondParentCommit()
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(2);
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .onSecondParentCommit()
+            .create();
+
+    // We want to be able to create such invalid comments for testing purposes (e.g. testing error
+    // handling or resilience of an endpoint) and hence we need to allow such invalid comments in
+    // the test API.
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(2);
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedOnAutoMergeCommit() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .onAutoMergeCommit()
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isNull();
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedAsResolved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newDraftComment().resolved().create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).unresolved().isFalse();
+  }
+
+  @Test
+  public void draftCommentCanBeCreatedAsUnresolved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newDraftComment().unresolved().create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).unresolved().isTrue();
+  }
+
+  @Test
+  public void draftReplyToDraftCommentCanBeCreated() {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newDraftComment().create();
+
+    // Gerrit's other APIs shouldn't support the creation of a draft reply to a draft comment but
+    // there's currently no reason to not support such a comment via the test API if a test really
+    // wants to create such a comment.
+    changeOperations
+        .change(changeId)
+        .currentPatchset()
+        .newDraftComment()
+        .parentUuid(parentCommentUuid)
+        .create();
+  }
+
+  @Test
+  public void draftReplyToPublishedCommentCanBeCreated() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newComment().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .parentUuid(parentCommentUuid)
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).inReplyTo().isEqualTo(parentCommentUuid);
+  }
+
+  @Test
+  public void tagCanBeAttachedToADraftComment() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .tag("my special tag")
+            .create();
+
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).tag().isEqualTo("my special tag");
+  }
+
+  @Test
+  public void draftCommentIsCreatedWithSpecifiedAuthor() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    Account.Id accountId = accountOperations.newAccount().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .author(accountId)
+            .create();
+
+    // A user can only retrieve their own draft comments.
+    requestScopeOperations.setApiUser(accountId);
+    List<CommentInfo> comments = getDraftCommentsFromServer(changeId);
+    // Draft comments never have the author field set. As a user can only retrieve their own draft
+    // comments, we implicitly know that the author was correctly set when we find the created
+    // comment in the draft comments of that user.
+    assertThatList(comments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+  }
+
+  @Test
+  public void draftCommentIsCreatedWithSpecifiedCreationTime() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    // Don't use nanos. NoteDb supports only second precision.
+    Instant creationTime =
+        LocalDateTime.of(2020, Month.SEPTEMBER, 15, 12, 10, 43).atZone(ZoneOffset.UTC).toInstant();
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .createdOn(creationTime)
+            .create();
+
+    Timestamp creationTimestamp = Timestamp.from(creationTime);
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).updated().isEqualTo(creationTimestamp);
+  }
+
+  @Test
+  public void zoneOfCreationDateOfDraftCommentCanBeOmitted() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    // As we don't care about the exact time zone internally used as a default, do a relative test
+    // so that we don't need to assert on exact instants in time. For a relative test, we need two
+    // comments whose creation date should be exactly the specified amount apart.
+    // Don't use nanos or millis. NoteDb supports only second precision.
+    LocalDateTime creationTime1 = LocalDateTime.of(2020, Month.SEPTEMBER, 15, 12, 10, 43);
+    LocalDateTime creationTime2 = creationTime1.plusMinutes(10);
+    String commentUuid1 =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .createdOn(creationTime1)
+            .create();
+    String commentUuid2 =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .createdOn(creationTime2)
+            .create();
+
+    CommentInfo comment1 = getDraftCommentFromServer(changeId, commentUuid1);
+    Instant comment1Creation = comment1.updated.toInstant();
+    CommentInfo comment2 = getDraftCommentFromServer(changeId, commentUuid2);
+    Instant comment2Creation = comment2.updated.toInstant();
+    Duration commentCreationDifference = Duration.between(comment1Creation, comment2Creation);
+    assertThat(commentCreationDifference).isEqualTo(Duration.ofMinutes(10));
+  }
+
+  @Test
+  public void noDraftCommentsAreCreatedOnCreationOfPublishedComment() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    changeOperations.change(changeId).currentPatchset().newComment().create();
+
+    List<CommentInfo> comments = getDraftCommentsFromServer(changeId);
+    assertThatList(comments).isEmpty();
+  }
+
+  @Test
+  public void noPublishedCommentsAreCreatedOnCreationOfDraftComment() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    changeOperations.change(changeId).currentPatchset().newDraftComment().create();
+
+    List<CommentInfo> comments = getCommentsFromServer(changeId);
+    assertThatList(comments).isEmpty();
+  }
+
+  @Test
+  public void robotCommentCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+    List<RobotCommentInfo> robotComments = getRobotCommentsFromServerFromCurrentPatchset(changeId);
+    assertThatList(robotComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+  }
+
+  @Test
+  public void robotCommentCanBeCreatedOnOlderPatchset() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    PatchSet.Id previousPatchsetId =
+        changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    changeOperations.change(changeId).newPatchset().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).patchset(previousPatchsetId).newRobotComment().create();
+
+    CommentInfo comment = getRobotCommentFromServer(previousPatchsetId, commentUuid);
+    assertThat(comment).uuid().isEqualTo(commentUuid);
+  }
+
+  @Test
+  public void robotCommentIsCreatedWithSpecifiedMessage() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .message("Test comment message")
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).message().isEqualTo("Test comment message");
+  }
+
+  @Test
+  public void robotCommentCanBeCreatedWithEmptyMessage() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().noMessage().create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).message().isNull();
+  }
+
+  @Test
+  public void patchsetLevelRobotCommentCanBeCreated() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .onPatchsetLevel()
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).path().isEqualTo(Patch.PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void fileRobotCommentCanBeCreated() throws Exception {
+    Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .onFileLevelOf("file1")
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).path().isEqualTo("file1");
+    assertThat(comment).line().isNull();
+    assertThat(comment).range().isNull();
+  }
+
+  @Test
+  public void lineRobotCommentCanBeCreated() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .onLine(3)
+            .ofFile("file1")
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).line().isEqualTo(3);
+    assertThat(comment).range().isNull();
+  }
+
+  @Test
+  public void rangeRobotCommentCanBeCreated() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .fromLine(2)
+            .charOffset(4)
+            .toLine(3)
+            .charOffset(5)
+            .ofFile("file1")
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).range().startLine().isEqualTo(2);
+    assertThat(comment).range().startCharacter().isEqualTo(4);
+    assertThat(comment).range().endLine().isEqualTo(3);
+    assertThat(comment).range().endCharacter().isEqualTo(5);
+    // Line is automatically filled from specified range. It's the end line.
+    assertThat(comment).line().isEqualTo(3);
+  }
+
+  @Test
+  public void robotCommentCanBeCreatedOnPatchsetCommit() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .onPatchsetCommit()
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    // Null is often used instead of Side.REVISION as Side.REVISION is the default.
+    assertThat(comment).side().isAnyOf(Side.REVISION, null);
+    assertThat(comment).parent().isNull();
+  }
+
+  @Test
+  public void robotCommentCanBeCreatedOnParentCommit() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .onParentCommit()
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(1);
+  }
+
+  @Test
+  public void robotCommentCanBeCreatedOnSecondParentCommit() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .onSecondParentCommit()
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(2);
+  }
+
+  @Test
+  public void robotCommentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .onSecondParentCommit()
+            .create();
+
+    // We want to be able to create such invalid robot comments for testing purposes (e.g. testing
+    // error handling or resilience of an endpoint) and hence we need to allow such invalid robot
+    // comments in the test API.
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(2);
+  }
+
+  @Test
+  public void robotCommentCanBeCreatedOnAutoMergeCommit() throws Exception {
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .onAutoMergeCommit()
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isNull();
+  }
+
+  @Test
+  public void replyToRobotCommentCanBeCreated() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .parentUuid(parentCommentUuid)
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).inReplyTo().isEqualTo(parentCommentUuid);
+  }
+
+  @Test
+  public void tagCanBeAttachedToARobotComment() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .tag("my special tag")
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).tag().isEqualTo("my special tag");
+  }
+
+  @Test
+  public void robotCommentIsCreatedWithSpecifiedAuthor() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    Account.Id accountId = accountOperations.newAccount().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .author(accountId)
+            .create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).author().id().isEqualTo(accountId.get());
+  }
+
+  @Test
+  public void robotCommentIsCreatedWithRobotId() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .robotId("robot-id")
+            .create();
+
+    RobotCommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).robotId().isEqualTo("robot-id");
+  }
+
+  @Test
+  public void robotCommentIsCreatedWithRobotRunId() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .robotId("robot-run-id")
+            .create();
+
+    RobotCommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).robotId().isEqualTo("robot-run-id");
+  }
+
+  @Test
+  public void robotCommentIsCreatedWithUrl() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().url("url").create();
+
+    RobotCommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).url().isEqualTo("url");
+  }
+
+  @Test
+  public void robotCommentIsCreatedWithProperty() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newRobotComment()
+            .addProperty("key", "value")
+            .create();
+
+    RobotCommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).properties().containsExactly("key", "value");
+  }
+
+  private List<CommentInfo> getCommentsFromServer(Change.Id changeId) throws RestApiException {
+    return gApi.changes().id(changeId.get()).commentsRequest().getAsList();
+  }
+
+  private List<RobotCommentInfo> getRobotCommentsFromServerFromCurrentPatchset(Change.Id changeId)
+      throws RestApiException {
+    return gApi.changes().id(changeId.get()).current().robotCommentsAsList();
+  }
+
+  private List<CommentInfo> getDraftCommentsFromServer(Change.Id changeId) throws RestApiException {
+    return gApi.changes().id(changeId.get()).draftsAsList();
+  }
+
+  private CommentInfo getCommentFromServer(Change.Id changeId, String uuid)
+      throws RestApiException {
+    return gApi.changes().id(changeId.get()).commentsRequest().getAsList().stream()
+        .filter(comment -> comment.id.equals(uuid))
+        .findAny()
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format("Comment %s not found on change %d", uuid, changeId.get())));
+  }
+
+  private RobotCommentInfo getRobotCommentFromServerInCurrentPatchset(
+      Change.Id changeId, String uuid) throws RestApiException {
+    return gApi.changes().id(changeId.get()).current().robotCommentsAsList().stream()
+        .filter(comment -> comment.id.equals(uuid))
+        .findAny()
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Robot Comment %s not found on change %d on the latest patchset",
+                        uuid, changeId.get())));
+  }
+
+  private RobotCommentInfo getRobotCommentFromServer(PatchSet.Id patchsetId, String uuid)
+      throws RestApiException {
+    return gApi.changes().id(patchsetId.changeId().toString())
+        .revision(patchsetId.getId().toString()).robotCommentsAsList().stream()
+        .filter(comment -> comment.id.equals(uuid))
+        .findAny()
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Robot Comment %s not found on change %d on patchset %d",
+                        uuid, patchsetId.changeId().get(), patchsetId.get())));
+  }
+
+  private CommentInfo getDraftCommentFromServer(Change.Id changeId, String uuid)
+      throws RestApiException {
+    return gApi.changes().id(changeId.get()).draftsAsList().stream()
+        .filter(comment -> comment.id.equals(uuid))
+        .findAny()
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Draft comment %s not found on change %d", uuid, changeId.get())));
+  }
+
+  private Correspondence<CommentInfo, String> hasUuid() {
+    return NullAwareCorrespondence.transforming(comment -> comment.id, "hasUuid");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index 96864d9..a003f9d 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -29,9 +29,9 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
-import java.util.Objects;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -616,28 +616,11 @@
   }
 
   private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
-    return Correspondence.from(
-        (actualAccount, expectedId) -> {
-          Account.Id accountId =
-              Optional.ofNullable(actualAccount)
-                  .map(account -> account._accountId)
-                  .map(Account::id)
-                  .orElse(null);
-          return Objects.equals(accountId, expectedId);
-        },
-        "has ID");
+    return NullAwareCorrespondence.transforming(
+        account -> Account.id(account._accountId), "has ID");
   }
 
   private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
-    return Correspondence.from(
-        (actualGroup, expectedUuid) -> {
-          AccountGroup.UUID groupUuid =
-              Optional.ofNullable(actualGroup)
-                  .map(group -> group.id)
-                  .map(AccountGroup::uuid)
-                  .orElse(null);
-          return Objects.equals(groupUuid, expectedUuid);
-        },
-        "has UUID");
+    return NullAwareCorrespondence.transforming(group -> AccountGroup.uuid(group.id), "has UUID");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 3228f39..7543ba8 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -30,24 +30,24 @@
 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;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.truth.Correspondence;
 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.Permission;
 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;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
@@ -88,6 +88,36 @@
   }
 
   @Test
+  public void specifiedBranchesAreCreatedInNewProject() throws Exception {
+    Project.NameKey project =
+        projectOperations
+            .newProject()
+            .branches("test-branch", "refs/heads/another-test-branch")
+            .create();
+
+    List<BranchInfo> branches = gApi.projects().name(project.get()).branches().get();
+    assertThat(branches)
+        .comparingElementsUsing(hasBranchName())
+        .containsAtLeast("refs/heads/test-branch", "refs/heads/another-test-branch");
+  }
+
+  @Test
+  public void specifiedBranchesAreNotCreatedInNewProjectIfNoEmptyCommitRequested()
+      throws Exception {
+    Project.NameKey project =
+        projectOperations
+            .newProject()
+            .branches("test-branch", "refs/heads/another-test-branch")
+            .noEmptyCommit()
+            .create();
+
+    List<BranchInfo> branches = gApi.projects().name(project.get()).branches().get();
+    assertThat(branches)
+        .comparingElementsUsing(hasBranchName())
+        .containsNoneOf("refs/heads/test-branch", "refs/heads/another-test-branch");
+  }
+
+  @Test
   public void permissionOnly() throws Exception {
     Project.NameKey key = projectOperations.newProject().permissionOnly(true).create();
     String head = gApi.projects().name(key.get()).head();
@@ -116,23 +146,6 @@
   }
 
   @Test
-  public void mutatingResultOfGetProjectConfigDoesNotMutateGlobalCachedValue() throws Exception {
-    Project.NameKey key = projectOperations.newProject().create();
-    ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
-    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.get(key).orElseThrow(illegalState(project)).getConfig();
-    assertThat(cachedProjectConfig2).isNotSameInstanceAs(projectConfig);
-    assertThat(cachedProjectConfig2.getProject().getDescription()).isEmpty();
-  }
-
-  @Test
   public void getProjectConfigNoRefsMetaConfig() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     deleteRefsMetaConfig(key);
@@ -617,4 +630,8 @@
       tr.delete(REFS_CONFIG);
     }
   }
+
+  private static Correspondence<BranchInfo, String> hasBranchName() {
+    return NullAwareCorrespondence.transforming(branch -> branch.ref, "hasName");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
index 8fc1677..e0d0d25 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
@@ -26,7 +26,7 @@
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_BATCH_CHANGES_LIMIT;
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
 import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
-import static com.google.gerrit.common.data.Permission.ABANDON;
+import static com.google.gerrit.entities.Permission.ABANDON;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
deleted file mode 100644
index e775cbc..0000000
--- a/javatests/com/google/gerrit/common/data/AccessSectionTest.java
+++ /dev/null
@@ -1,249 +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.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.common.collect.ImmutableList;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Locale;
-import org.junit.Before;
-import org.junit.Test;
-
-public class AccessSectionTest {
-  private static final String REF_PATTERN = "refs/heads/master";
-
-  private AccessSection accessSection;
-
-  @Before
-  public void setup() {
-    this.accessSection = new AccessSection(REF_PATTERN);
-  }
-
-  @Test
-  public void getName() {
-    assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
-  }
-
-  @Test
-  public void getEmptyPermissions() {
-    assertThat(accessSection.getPermissions()).isNotNull();
-    assertThat(accessSection.getPermissions()).isEmpty();
-  }
-
-  @Test
-  public void setAndGetPermissions() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
-        .inOrder();
-
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    accessSection.setPermissions(ImmutableList.of(submitPermission));
-    assertThat(accessSection.getPermissions()).containsExactly(submitPermission);
-    assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
-  }
-
-  @Test
-  public void cannotSetDuplicatePermissions() {
-    assertThrows(
-        IllegalArgumentException.class,
-        () ->
-            accessSection.setPermissions(
-                ImmutableList.of(
-                    new Permission(Permission.ABANDON), new Permission(Permission.ABANDON))));
-  }
-
-  @Test
-  public void cannotSetPermissionsWithConflictingNames() {
-    Permission abandonPermissionLowerCase =
-        new Permission(Permission.ABANDON.toLowerCase(Locale.US));
-    Permission abandonPermissionUpperCase =
-        new Permission(Permission.ABANDON.toUpperCase(Locale.US));
-
-    assertThrows(
-        IllegalArgumentException.class,
-        () ->
-            accessSection.setPermissions(
-                ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase)));
-  }
-
-  @Test
-  public void getNonExistingPermission() {
-    assertThat(accessSection.getPermission("non-existing")).isNull();
-    assertThat(accessSection.getPermission("non-existing", false)).isNull();
-  }
-
-  @Test
-  public void getPermission() {
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    accessSection.setPermissions(ImmutableList.of(submitPermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
-    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null));
-  }
-
-  @Test
-  public void getPermissionWithOtherCase() {
-    Permission submitPermissionLowerCase = new Permission(Permission.SUBMIT.toLowerCase(Locale.US));
-    accessSection.setPermissions(ImmutableList.of(submitPermissionLowerCase));
-    assertThat(accessSection.getPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
-        .isEqualTo(submitPermissionLowerCase);
-  }
-
-  @Test
-  public void createMissingPermissionOnGet() {
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-
-    assertThat(accessSection.getPermission(Permission.SUBMIT, true))
-        .isEqualTo(new Permission(Permission.SUBMIT));
-
-    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null, true));
-  }
-
-  @Test
-  public void addPermission() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-
-    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    accessSection.addPermission(submitPermission);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission, submitPermission)
-        .inOrder();
-    assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
-  }
-
-  @Test
-  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-
-    List<Permission> permissions = new ArrayList<>();
-    permissions.add(abandonPermission);
-    permissions.add(rebasePermission);
-    accessSection.setPermissions(permissions);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    permissions.add(submitPermission);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-  }
-
-  @Test
-  public void removePermission() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
-
-    accessSection.remove(submitPermission);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
-        .inOrder();
-    assertThrows(NullPointerException.class, () -> accessSection.remove(null));
-  }
-
-  @Test
-  public void removePermissionByName() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
-
-    accessSection.removePermission(Permission.SUBMIT);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
-        .inOrder();
-
-    assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
-  }
-
-  @Test
-  public void removePermissionByNameOtherCase() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-
-    String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
-    String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
-    Permission submitPermissionLowerCase = new Permission(submitLowerCase);
-
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission, submitPermissionLowerCase));
-    assertThat(accessSection.getPermission(submitLowerCase)).isNotNull();
-    assertThat(accessSection.getPermission(submitUpperCase)).isNotNull();
-
-    accessSection.removePermission(submitUpperCase);
-    assertThat(accessSection.getPermission(submitLowerCase)).isNull();
-    assertThat(accessSection.getPermission(submitUpperCase)).isNull();
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
-        .inOrder();
-  }
-
-  @Test
-  public void mergeAccessSections() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-
-    AccessSection accessSection1 = new AccessSection("refs/heads/foo");
-    accessSection1.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-
-    AccessSection accessSection2 = new AccessSection("refs/heads/bar");
-    accessSection2.setPermissions(ImmutableList.of(rebasePermission, submitPermission));
-
-    accessSection1.mergeFrom(accessSection2);
-    assertThat(accessSection1.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission, submitPermission)
-        .inOrder();
-    assertThrows(NullPointerException.class, () -> accessSection.mergeFrom(null));
-  }
-
-  @Test
-  public void testEquals() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-
-    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-
-    AccessSection accessSectionSamePermissionsOtherRef = new AccessSection("refs/heads/other");
-    accessSectionSamePermissionsOtherRef.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission));
-    assertThat(accessSection.equals(accessSectionSamePermissionsOtherRef)).isFalse();
-
-    AccessSection accessSectionOther = new AccessSection(REF_PATTERN);
-    accessSectionOther.setPermissions(ImmutableList.of(abandonPermission));
-    assertThat(accessSection.equals(accessSectionOther)).isFalse();
-
-    accessSectionOther.addPermission(rebasePermission);
-    assertThat(accessSection.equals(accessSectionOther)).isTrue();
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 25b55c7..593b635 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -19,6 +19,8 @@
 
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import org.junit.Test;
 
 public class GroupReferenceTest {
@@ -58,7 +60,7 @@
   public void create() {
     AccountGroup.UUID uuid = AccountGroup.uuid("uuid");
     String name = "foo";
-    GroupReference groupReference = new GroupReference(uuid, name);
+    GroupReference groupReference = GroupReference.create(uuid, name);
     assertThat(groupReference.getUUID()).isEqualTo(uuid);
     assertThat(groupReference.getName()).isEqualTo(name);
   }
@@ -68,7 +70,7 @@
     // GroupReferences where the UUID is null are used to represent groups from project.config that
     // cannot be resolved.
     String name = "foo";
-    GroupReference groupReference = new GroupReference(name);
+    GroupReference groupReference = GroupReference.create(name);
     assertThat(groupReference.getUUID()).isNull();
     assertThat(groupReference.getName()).isEqualTo(name);
   }
@@ -76,7 +78,7 @@
   @Test
   public void cannotCreateWithoutName() {
     assertThrows(
-        NullPointerException.class, () -> new GroupReference(AccountGroup.uuid("uuid"), null));
+        NullPointerException.class, () -> GroupReference.create(AccountGroup.uuid("uuid"), null));
   }
 
   @Test
@@ -98,40 +100,9 @@
   }
 
   @Test
-  public void getAndSetUuid() {
-    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
-    String name = "foo";
-    GroupReference groupReference = new GroupReference(uuid, name);
-    assertThat(groupReference.getUUID()).isEqualTo(uuid);
-
-    AccountGroup.UUID uuid2 = AccountGroup.uuid("uuid-bar");
-    groupReference.setUUID(uuid2);
-    assertThat(groupReference.getUUID()).isEqualTo(uuid2);
-
-    // GroupReferences where the UUID is null are used to represent groups from project.config that
-    // cannot be resolved.
-    groupReference.setUUID(null);
-    assertThat(groupReference.getUUID()).isNull();
-  }
-
-  @Test
-  public void getAndSetName() {
-    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
-    String name = "foo";
-    GroupReference groupReference = new GroupReference(uuid, name);
-    assertThat(groupReference.getName()).isEqualTo(name);
-
-    String name2 = "bar";
-    groupReference.setName(name2);
-    assertThat(groupReference.getName()).isEqualTo(name2);
-
-    assertThrows(NullPointerException.class, () -> groupReference.setName(null));
-  }
-
-  @Test
   public void toConfigValue() {
     String name = "foo";
-    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-foo"), name);
+    GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-foo"), name);
     assertThat(groupReference.toConfigValue()).isEqualTo("group " + name);
   }
 
@@ -142,9 +113,9 @@
     String name1 = "foo";
     String name2 = "bar";
 
-    GroupReference groupReference1 = new GroupReference(uuid1, name1);
-    GroupReference groupReference2 = new GroupReference(uuid1, name2);
-    GroupReference groupReference3 = new GroupReference(uuid2, name1);
+    GroupReference groupReference1 = GroupReference.create(uuid1, name1);
+    GroupReference groupReference2 = GroupReference.create(uuid1, name2);
+    GroupReference groupReference3 = GroupReference.create(uuid2, name1);
 
     assertThat(groupReference1.equals(groupReference2)).isTrue();
     assertThat(groupReference1.equals(groupReference3)).isFalse();
@@ -154,10 +125,10 @@
   @Test
   public void testHashcode() {
     AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid1");
-    assertThat(new GroupReference(uuid1, "foo").hashCode())
-        .isEqualTo(new GroupReference(uuid1, "bar").hashCode());
+    assertThat(GroupReference.create(uuid1, "foo").hashCode())
+        .isEqualTo(GroupReference.create(uuid1, "bar").hashCode());
 
     // Check that the following calls don't fail with an exception.
-    new GroupReference("bar").hashCode();
+    GroupReference.create("bar").hashCode();
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java b/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
deleted file mode 100644
index 6f5232b..0000000
--- a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
+++ /dev/null
@@ -1,145 +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.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-import org.junit.Test;
-
-public class LabelFunctionTest {
-  private static final String LABEL_NAME = "Verified";
-  private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
-  private static final Change.Id CHANGE_ID = Change.id(100);
-  private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
-  private static final LabelType VERIFIED_LABEL = makeLabel();
-  private static final PatchSetApproval APPROVAL_2 = makeApproval(2);
-  private static final PatchSetApproval APPROVAL_1 = makeApproval(1);
-  private static final PatchSetApproval APPROVAL_0 = makeApproval(0);
-  private static final PatchSetApproval APPROVAL_M1 = makeApproval(-1);
-  private static final PatchSetApproval APPROVAL_M2 = makeApproval(-2);
-
-  @Test
-  public void checkLabelNameIsCorrect() {
-    for (LabelFunction function : LabelFunction.values()) {
-      SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-      assertThat(myLabel.label).isEqualTo("Verified");
-    }
-  }
-
-  @Test
-  public void checkFunctionDoesNothing() {
-    checkNothingHappens(LabelFunction.NO_BLOCK);
-    checkNothingHappens(LabelFunction.NO_OP);
-    checkNothingHappens(LabelFunction.PATCH_SET_LOCK);
-    checkNothingHappens(LabelFunction.ANY_WITH_BLOCK);
-
-    checkLabelIsRequired(LabelFunction.MAX_WITH_BLOCK);
-    checkLabelIsRequired(LabelFunction.MAX_NO_BLOCK);
-  }
-
-  @Test
-  public void checkBlockWorks() {
-    checkBlockWorks(LabelFunction.ANY_WITH_BLOCK);
-    checkBlockWorks(LabelFunction.MAX_WITH_BLOCK);
-  }
-
-  @Test
-  public void checkMaxWorks() {
-    checkMaxIsEnforced(LabelFunction.MAX_NO_BLOCK);
-    checkMaxIsEnforced(LabelFunction.MAX_WITH_BLOCK);
-
-    checkMaxValidatesTheLabel(LabelFunction.MAX_NO_BLOCK);
-    checkMaxValidatesTheLabel(LabelFunction.MAX_WITH_BLOCK);
-  }
-
-  @Test
-  public void checkMaxNoBlockIgnoresMin() {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_M2, APPROVAL_2, APPROVAL_M2);
-
-    SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
-  }
-
-  private static LabelType makeLabel() {
-    List<LabelValue> values = new ArrayList<>();
-    // The label text is irrelevant here, only the numerical value is used
-    values.add(new LabelValue((short) -2, "Great job, please fix compilation."));
-    values.add(new LabelValue((short) -1, "Really good, please make some minor changes."));
-    values.add(new LabelValue((short) 0, "No vote."));
-    values.add(new LabelValue((short) 1, "Closest thing perfection."));
-    values.add(new LabelValue((short) 2, "Perfect!"));
-    return new LabelType(LABEL_NAME, values);
-  }
-
-  private static PatchSetApproval makeApproval(int value) {
-    return PatchSetApproval.builder()
-        .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
-        .value(value)
-        .granted(Date.from(Instant.now()))
-        .build();
-  }
-
-  private static void checkBlockWorks(LabelFunction function) {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_M2, APPROVAL_2);
-
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.REJECT);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.accountId());
-  }
-
-  private static void checkNothingHappens(LabelFunction function) {
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.MAY);
-    assertThat(myLabel.appliedBy).isNull();
-  }
-
-  private static void checkLabelIsRequired(LabelFunction function) {
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
-    assertThat(myLabel.appliedBy).isNull();
-  }
-
-  private static void checkMaxIsEnforced(LabelFunction function) {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_0);
-
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
-  }
-
-  private static void checkMaxValidatesTheLabel(LabelFunction function) {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_2, APPROVAL_M1);
-
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/LabelTypeTest.java b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
deleted file mode 100644
index 6c3befb..0000000
--- a/javatests/com/google/gerrit/common/data/LabelTypeTest.java
+++ /dev/null
@@ -1,48 +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.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import org.junit.Test;
-
-public class LabelTypeTest {
-  @Test
-  public void sortLabelValues() {
-    LabelValue v0 = new LabelValue((short) 0, "Zero");
-    LabelValue v1 = new LabelValue((short) 1, "One");
-    LabelValue v2 = new LabelValue((short) 2, "Two");
-    LabelType types = new LabelType("Label", ImmutableList.of(v2, v0, v1));
-    assertThat(types.getValues()).containsExactly(v0, v1, v2).inOrder();
-  }
-
-  @Test
-  public void insertMissingLabelValues() {
-    LabelValue v0 = new LabelValue((short) 0, "Zero");
-    LabelValue v2 = new LabelValue((short) 2, "Two");
-    LabelValue v5 = new LabelValue((short) 5, "Five");
-    LabelType types = new LabelType("Label", ImmutableList.of(v2, v5, v0));
-    assertThat(types.getValues())
-        .containsExactly(
-            v0,
-            new LabelValue((short) 1, ""),
-            v2,
-            new LabelValue((short) 3, ""),
-            new LabelValue((short) 4, ""),
-            v5)
-        .inOrder();
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
deleted file mode 100644
index d815dbc..0000000
--- a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
+++ /dev/null
@@ -1,395 +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.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.entities.AccountGroup;
-import org.junit.Before;
-import org.junit.Test;
-
-public class PermissionRuleTest {
-  private GroupReference groupReference;
-  private PermissionRule permissionRule;
-
-  @Before
-  public void setup() {
-    this.groupReference = new GroupReference(AccountGroup.uuid("uuid"), "group");
-    this.permissionRule = new PermissionRule(groupReference);
-  }
-
-  @Test
-  public void getAndSetAction() {
-    assertThat(permissionRule.getAction()).isEqualTo(Action.ALLOW);
-
-    permissionRule.setAction(Action.DENY);
-    assertThat(permissionRule.getAction()).isEqualTo(Action.DENY);
-  }
-
-  @Test
-  public void cannotSetActionToNull() {
-    assertThrows(NullPointerException.class, () -> permissionRule.setAction(null));
-  }
-
-  @Test
-  public void setDeny() {
-    assertThat(permissionRule.isDeny()).isFalse();
-
-    permissionRule.setDeny();
-    assertThat(permissionRule.isDeny()).isTrue();
-  }
-
-  @Test
-  public void setBlock() {
-    assertThat(permissionRule.isBlock()).isFalse();
-
-    permissionRule.setBlock();
-    assertThat(permissionRule.isBlock()).isTrue();
-  }
-
-  @Test
-  public void setForce() {
-    assertThat(permissionRule.getForce()).isFalse();
-
-    permissionRule.setForce(true);
-    assertThat(permissionRule.getForce()).isTrue();
-
-    permissionRule.setForce(false);
-    assertThat(permissionRule.getForce()).isFalse();
-  }
-
-  @Test
-  public void setMin() {
-    assertThat(permissionRule.getMin()).isEqualTo(0);
-
-    permissionRule.setMin(-2);
-    assertThat(permissionRule.getMin()).isEqualTo(-2);
-
-    permissionRule.setMin(2);
-    assertThat(permissionRule.getMin()).isEqualTo(2);
-  }
-
-  @Test
-  public void setMax() {
-    assertThat(permissionRule.getMax()).isEqualTo(0);
-
-    permissionRule.setMax(2);
-    assertThat(permissionRule.getMax()).isEqualTo(2);
-
-    permissionRule.setMax(-2);
-    assertThat(permissionRule.getMax()).isEqualTo(-2);
-  }
-
-  @Test
-  public void setRange() {
-    assertThat(permissionRule.getMin()).isEqualTo(0);
-    assertThat(permissionRule.getMax()).isEqualTo(0);
-
-    permissionRule.setRange(-2, 2);
-    assertThat(permissionRule.getMin()).isEqualTo(-2);
-    assertThat(permissionRule.getMax()).isEqualTo(2);
-
-    permissionRule.setRange(2, -2);
-    assertThat(permissionRule.getMin()).isEqualTo(-2);
-    assertThat(permissionRule.getMax()).isEqualTo(2);
-
-    permissionRule.setRange(1, 1);
-    assertThat(permissionRule.getMin()).isEqualTo(1);
-    assertThat(permissionRule.getMax()).isEqualTo(1);
-  }
-
-  @Test
-  public void hasRange() {
-    assertThat(permissionRule.hasRange()).isFalse();
-
-    permissionRule.setMin(-1);
-    assertThat(permissionRule.hasRange()).isTrue();
-
-    permissionRule.setMax(1);
-    assertThat(permissionRule.hasRange()).isTrue();
-  }
-
-  @Test
-  public void getGroup() {
-    assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
-  }
-
-  @Test
-  public void setGroup() {
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    assertThat(groupReference2).isNotEqualTo(groupReference);
-
-    assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
-
-    permissionRule.setGroup(groupReference2);
-    assertThat(permissionRule.getGroup()).isEqualTo(groupReference2);
-  }
-
-  @Test
-  public void mergeFromAnyBlock() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isBlock()).isFalse();
-    assertThat(permissionRule2.isBlock()).isFalse();
-
-    permissionRule2.setBlock();
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isBlock()).isTrue();
-    assertThat(permissionRule2.isBlock()).isTrue();
-
-    permissionRule2.setDeny();
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isBlock()).isTrue();
-    assertThat(permissionRule2.isBlock()).isFalse();
-
-    permissionRule2.setAction(Action.BATCH);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isBlock()).isTrue();
-    assertThat(permissionRule2.isBlock()).isFalse();
-  }
-
-  @Test
-  public void mergeFromAnyDeny() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isDeny()).isFalse();
-    assertThat(permissionRule2.isDeny()).isFalse();
-
-    permissionRule2.setDeny();
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isDeny()).isTrue();
-    assertThat(permissionRule2.isDeny()).isTrue();
-
-    permissionRule2.setAction(Action.BATCH);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isDeny()).isTrue();
-    assertThat(permissionRule2.isDeny()).isFalse();
-  }
-
-  @Test
-  public void mergeFromAnyBatch() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getAction()).isNotEqualTo(Action.BATCH);
-    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
-
-    permissionRule2.setAction(Action.BATCH);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
-    assertThat(permissionRule2.getAction()).isEqualTo(Action.BATCH);
-
-    permissionRule2.setAction(Action.ALLOW);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
-    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
-  }
-
-  @Test
-  public void mergeFromAnyForce() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getForce()).isFalse();
-    assertThat(permissionRule2.getForce()).isFalse();
-
-    permissionRule2.setForce(true);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getForce()).isTrue();
-    assertThat(permissionRule2.getForce()).isTrue();
-
-    permissionRule2.setForce(false);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getForce()).isTrue();
-    assertThat(permissionRule2.getForce()).isFalse();
-  }
-
-  @Test
-  public void mergeFromMergeRange() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-    permissionRule1.setRange(-1, 2);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-    permissionRule2.setRange(-2, 1);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getMin()).isEqualTo(-2);
-    assertThat(permissionRule1.getMax()).isEqualTo(2);
-    assertThat(permissionRule2.getMin()).isEqualTo(-2);
-    assertThat(permissionRule2.getMax()).isEqualTo(1);
-  }
-
-  @Test
-  public void mergeFromGroupNotChanged() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getGroup()).isEqualTo(groupReference1);
-    assertThat(permissionRule2.getGroup()).isEqualTo(groupReference2);
-  }
-
-  @Test
-  public void asString() {
-    assertThat(permissionRule.asString(true)).isEqualTo("group " + groupReference.getName());
-
-    permissionRule.setDeny();
-    assertThat(permissionRule.asString(true)).isEqualTo("deny group " + groupReference.getName());
-
-    permissionRule.setBlock();
-    assertThat(permissionRule.asString(true)).isEqualTo("block group " + groupReference.getName());
-
-    permissionRule.setAction(Action.BATCH);
-    assertThat(permissionRule.asString(true)).isEqualTo("batch group " + groupReference.getName());
-
-    permissionRule.setAction(Action.INTERACTIVE);
-    assertThat(permissionRule.asString(true))
-        .isEqualTo("interactive group " + groupReference.getName());
-
-    permissionRule.setForce(true);
-    assertThat(permissionRule.asString(true))
-        .isEqualTo("interactive +force group " + groupReference.getName());
-
-    permissionRule.setAction(Action.ALLOW);
-    assertThat(permissionRule.asString(true)).isEqualTo("+force group " + groupReference.getName());
-
-    permissionRule.setMax(1);
-    assertThat(permissionRule.asString(true))
-        .isEqualTo("+force +0..+1 group " + groupReference.getName());
-
-    permissionRule.setMin(-1);
-    assertThat(permissionRule.asString(true))
-        .isEqualTo("+force -1..+1 group " + groupReference.getName());
-
-    assertThat(permissionRule.asString(false))
-        .isEqualTo("+force group " + groupReference.getName());
-  }
-
-  @Test
-  public void fromString() {
-    PermissionRule permissionRule = PermissionRule.fromString("group A", true);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("deny group A", true);
-    assertPermissionRule(permissionRule, "A", Action.DENY, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("block group A", true);
-    assertPermissionRule(permissionRule, "A", Action.BLOCK, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("batch group A", true);
-    assertPermissionRule(permissionRule, "A", Action.BATCH, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("interactive group A", true);
-    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("interactive +force group A", true);
-    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, true, 0, 0);
-
-    permissionRule = PermissionRule.fromString("+force group A", true);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
-
-    permissionRule = PermissionRule.fromString("+force +0..+1 group A", true);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 1);
-
-    permissionRule = PermissionRule.fromString("+force -1..+1 group A", true);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, -1, 1);
-
-    permissionRule = PermissionRule.fromString("+force group A", false);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
-  }
-
-  @Test
-  public void parseInt() {
-    assertThat(PermissionRule.parseInt("0")).isEqualTo(0);
-    assertThat(PermissionRule.parseInt("+0")).isEqualTo(0);
-    assertThat(PermissionRule.parseInt("-0")).isEqualTo(0);
-    assertThat(PermissionRule.parseInt("1")).isEqualTo(1);
-    assertThat(PermissionRule.parseInt("+1")).isEqualTo(1);
-    assertThat(PermissionRule.parseInt("-1")).isEqualTo(-1);
-  }
-
-  @Test
-  public void testEquals() {
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRuleOther = new PermissionRule(groupReference2);
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setGroup(groupReference);
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-
-    permissionRule.setDeny();
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setDeny();
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-
-    permissionRule.setForce(true);
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setForce(true);
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-
-    permissionRule.setMin(-1);
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setMin(-1);
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-
-    permissionRule.setMax(1);
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setMax(1);
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-  }
-
-  private void assertPermissionRule(
-      PermissionRule permissionRule,
-      String expectedGroupName,
-      Action expectedAction,
-      boolean expectedForce,
-      int expectedMin,
-      int expectedMax) {
-    assertThat(permissionRule.getGroup().getName()).isEqualTo(expectedGroupName);
-    assertThat(permissionRule.getAction()).isEqualTo(expectedAction);
-    assertThat(permissionRule.getForce()).isEqualTo(expectedForce);
-    assertThat(permissionRule.getMin()).isEqualTo(expectedMin);
-    assertThat(permissionRule.getMax()).isEqualTo(expectedMax);
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
deleted file mode 100644
index 1012eff..0000000
--- a/javatests/com/google/gerrit/common/data/PermissionTest.java
+++ /dev/null
@@ -1,325 +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.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.AccountGroup;
-import java.util.ArrayList;
-import java.util.List;
-import org.junit.Before;
-import org.junit.Test;
-
-public class PermissionTest {
-  private static final String PERMISSION_NAME = "foo";
-
-  private Permission permission;
-
-  @Before
-  public void setup() {
-    this.permission = new Permission(PERMISSION_NAME);
-  }
-
-  @Test
-  public void isPermission() {
-    assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
-    assertThat(Permission.isPermission("no-permission")).isFalse();
-
-    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission("Code-Review")).isFalse();
-  }
-
-  @Test
-  public void hasRange() {
-    assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
-    assertThat(Permission.hasRange("no-permission")).isFalse();
-
-    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange("Code-Review")).isFalse();
-  }
-
-  @Test
-  public void isLabel() {
-    assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
-    assertThat(Permission.isLabel("no-permission")).isFalse();
-
-    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
-    assertThat(Permission.isLabel("Code-Review")).isFalse();
-  }
-
-  @Test
-  public void isLabelAs() {
-    assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
-    assertThat(Permission.isLabelAs("no-permission")).isFalse();
-
-    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
-    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
-  }
-
-  @Test
-  public void forLabel() {
-    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
-  }
-
-  @Test
-  public void forLabelAs() {
-    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
-  }
-
-  @Test
-  public void extractLabel() {
-    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
-        .isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel("Code-Review")).isNull();
-    assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
-  }
-
-  @Test
-  public void canBeOnAllProjects() {
-    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
-    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
-    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
-        .isTrue();
-    assertThat(
-            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
-        .isTrue();
-
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
-        .isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
-        .isTrue();
-  }
-
-  @Test
-  public void getName() {
-    assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
-  }
-
-  @Test
-  public void getLabel() {
-    assertThat(new Permission(Permission.LABEL + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(new Permission(Permission.LABEL_AS + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(new Permission("Code-Review").getLabel()).isNull();
-    assertThat(new Permission(Permission.ABANDON).getLabel()).isNull();
-  }
-
-  @Test
-  public void exclusiveGroup() {
-    assertThat(permission.getExclusiveGroup()).isFalse();
-
-    permission.setExclusiveGroup(true);
-    assertThat(permission.getExclusiveGroup()).isTrue();
-
-    permission.setExclusiveGroup(false);
-    assertThat(permission.getExclusiveGroup()).isFalse();
-  }
-
-  @Test
-  public void noExclusiveGroupOnOwnerPermission() {
-    Permission permission = new Permission(Permission.OWNER);
-    assertThat(permission.getExclusiveGroup()).isFalse();
-
-    permission.setExclusiveGroup(true);
-    assertThat(permission.getExclusiveGroup()).isFalse();
-  }
-
-  @Test
-  public void getEmptyRules() {
-    assertThat(permission.getRules()).isNotNull();
-    assertThat(permission.getRules()).isEmpty();
-  }
-
-  @Test
-  public void setAndGetRules() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
-
-    PermissionRule permissionRule3 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
-    permission.setRules(ImmutableList.of(permissionRule3));
-    assertThat(permission.getRules()).containsExactly(permissionRule3);
-  }
-
-  @Test
-  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
-
-    List<PermissionRule> rules = new ArrayList<>();
-    rules.add(permissionRule1);
-    rules.add(permissionRule2);
-    permission.setRules(rules);
-    assertThat(permission.getRule(groupReference3)).isNull();
-
-    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
-    rules.add(permissionRule3);
-    assertThat(permission.getRule(groupReference3)).isNull();
-  }
-
-  @Test
-  public void getNonExistingRule() {
-    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
-    assertThat(permission.getRule(groupReference)).isNull();
-    assertThat(permission.getRule(groupReference, false)).isNull();
-  }
-
-  @Test
-  public void getRule() {
-    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
-    PermissionRule permissionRule = new PermissionRule(groupReference);
-    permission.setRules(ImmutableList.of(permissionRule));
-    assertThat(permission.getRule(groupReference)).isEqualTo(permissionRule);
-  }
-
-  @Test
-  public void createMissingRuleOnGet() {
-    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
-    assertThat(permission.getRule(groupReference)).isNull();
-
-    assertThat(permission.getRule(groupReference, true))
-        .isEqualTo(new PermissionRule(groupReference));
-  }
-
-  @Test
-  public void addRule() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
-    assertThat(permission.getRule(groupReference3)).isNull();
-
-    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
-    permission.add(permissionRule3);
-    assertThat(permission.getRule(groupReference3)).isEqualTo(permissionRule3);
-    assertThat(permission.getRules())
-        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
-        .inOrder();
-  }
-
-  @Test
-  public void removeRule() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
-    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
-    assertThat(permission.getRule(groupReference3)).isNotNull();
-
-    permission.remove(permissionRule3);
-    assertThat(permission.getRule(groupReference3)).isNull();
-    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
-  }
-
-  @Test
-  public void removeRuleByGroupReference() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
-    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
-    assertThat(permission.getRule(groupReference3)).isNotNull();
-
-    permission.removeRule(groupReference3);
-    assertThat(permission.getRule(groupReference3)).isNull();
-    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
-  }
-
-  @Test
-  public void clearRules() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    assertThat(permission.getRules()).isNotEmpty();
-
-    permission.clearRules();
-    assertThat(permission.getRules()).isEmpty();
-  }
-
-  @Test
-  public void mergePermissions() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    PermissionRule permissionRule3 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
-
-    Permission permission1 = new Permission("foo");
-    permission1.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-
-    Permission permission2 = new Permission("bar");
-    permission2.setRules(ImmutableList.of(permissionRule2, permissionRule3));
-
-    permission1.mergeFrom(permission2);
-    assertThat(permission1.getRules())
-        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
-        .inOrder();
-  }
-
-  @Test
-  public void testEquals() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-
-    Permission permissionSameRulesOtherName = new Permission("bar");
-    permissionSameRulesOtherName.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
-
-    Permission permissionSameRulesSameNameOtherExclusiveGroup = new Permission("foo");
-    permissionSameRulesSameNameOtherExclusiveGroup.setRules(
-        ImmutableList.of(permissionRule1, permissionRule2));
-    permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
-    assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
-
-    Permission permissionOther = new Permission(PERMISSION_NAME);
-    permissionOther.setRules(ImmutableList.of(permissionRule1));
-    assertThat(permission.equals(permissionOther)).isFalse();
-
-    permissionOther.add(permissionRule2);
-    assertThat(permission.equals(permissionOther)).isTrue();
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
deleted file mode 100644
index 5386b87..0000000
--- a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
+++ /dev/null
@@ -1,70 +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.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import org.junit.Test;
-
-public class SubmitRecordTest {
-  private static final SubmitRecord OK_RECORD;
-  private static final SubmitRecord FORCED_RECORD;
-  private static final SubmitRecord NOT_READY_RECORD;
-
-  static {
-    OK_RECORD = new SubmitRecord();
-    OK_RECORD.status = SubmitRecord.Status.OK;
-
-    FORCED_RECORD = new SubmitRecord();
-    FORCED_RECORD.status = SubmitRecord.Status.FORCED;
-
-    NOT_READY_RECORD = new SubmitRecord();
-    NOT_READY_RECORD.status = SubmitRecord.Status.NOT_READY;
-  }
-
-  @Test
-  public void okIfAllOkay() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
-    submitRecords.add(OK_RECORD);
-
-    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
-  }
-
-  @Test
-  public void okWhenEmpty() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
-
-    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
-  }
-
-  @Test
-  public void okWhenForced() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
-    submitRecords.add(FORCED_RECORD);
-
-    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
-  }
-
-  @Test
-  public void emptyResultIfInvalid() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
-    submitRecords.add(NOT_READY_RECORD);
-    submitRecords.add(OK_RECORD);
-
-    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index c330961..b4fb153 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -39,12 +39,8 @@
 
   private static String getImageName(ElasticVersion version) {
     switch (version) {
-      case V7_6:
-        return "blacktop/elasticsearch:7.6.2";
-      case V7_7:
-        return "blacktop/elasticsearch:7.7.1";
-      case V7_8:
-        return "blacktop/elasticsearch:7.8.1";
+      case V7_16:
+        return "gerritforge/elasticsearch:7.16.2";
     }
     throw new IllegalStateException("No tests for version: " + version.name());
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index 4826490..39517d5 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -36,7 +36,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
     }
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index d9a4d2e..5d64d0a 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -46,7 +46,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
       client = HttpAsyncClients.createDefault();
       client.start();
     }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 0fc96f8..645f889 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -36,7 +36,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
     }
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index 1e56af9..8d7f5f8 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -36,7 +36,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
     }
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index 2ce3a2c..bfb332e 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -22,14 +22,7 @@
 public class ElasticVersionTest {
   @Test
   public void supportedVersion() throws Exception {
-    assertThat(ElasticVersion.forVersion("7.6.0")).isEqualTo(ElasticVersion.V7_6);
-    assertThat(ElasticVersion.forVersion("7.6.1")).isEqualTo(ElasticVersion.V7_6);
-
-    assertThat(ElasticVersion.forVersion("7.7.0")).isEqualTo(ElasticVersion.V7_7);
-    assertThat(ElasticVersion.forVersion("7.7.1")).isEqualTo(ElasticVersion.V7_7);
-
-    assertThat(ElasticVersion.forVersion("7.8.0")).isEqualTo(ElasticVersion.V7_8);
-    assertThat(ElasticVersion.forVersion("7.8.1")).isEqualTo(ElasticVersion.V7_8);
+    assertThat(ElasticVersion.forVersion("7.16.2")).isEqualTo(ElasticVersion.V7_16);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/entities/AccessSectionTest.java b/javatests/com/google/gerrit/entities/AccessSectionTest.java
new file mode 100644
index 0000000..06860b0
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/AccessSectionTest.java
@@ -0,0 +1,242 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccessSectionTest {
+  private static final String REF_PATTERN = "refs/heads/master";
+
+  private AccessSection.Builder accessSection;
+
+  @Before
+  public void setup() {
+    this.accessSection = AccessSection.builder(REF_PATTERN);
+  }
+
+  @Test
+  public void getName() {
+    assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
+  }
+
+  @Test
+  public void getEmptyPermissions() {
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermissions()).isNotNull();
+    assertThat(builtAccessSection.getPermissions()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetPermissions() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+    accessSection.modifyPermissions(
+        permissions -> {
+          permissions.clear();
+          permissions.add(abandonPermission);
+          permissions.add(rebasePermission);
+        });
+
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermissions()).hasSize(2);
+    assertThat(builtAccessSection.getPermission(abandonPermission.getName())).isNotNull();
+    assertThat(builtAccessSection.getPermission(rebasePermission.getName())).isNotNull();
+
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+    accessSection.modifyPermissions(
+        p -> {
+          p.clear();
+          p.add(submitPermission);
+        });
+    builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermissions()).hasSize(1);
+    assertThat(builtAccessSection.getPermission(submitPermission.getName())).isNotNull();
+    assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
+  }
+
+  @Test
+  public void cannotSetDuplicatePermissions() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection
+                .addPermission(Permission.builder(Permission.ABANDON))
+                .addPermission(Permission.builder(Permission.ABANDON))
+                .build());
+  }
+
+  @Test
+  public void cannotSetPermissionsWithConflictingNames() {
+    Permission.Builder abandonPermissionLowerCase =
+        Permission.builder(Permission.ABANDON.toLowerCase(Locale.US));
+    Permission.Builder abandonPermissionUpperCase =
+        Permission.builder(Permission.ABANDON.toUpperCase(Locale.US));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection
+                .addPermission(abandonPermissionLowerCase)
+                .addPermission(abandonPermissionUpperCase)
+                .build());
+  }
+
+  @Test
+  public void getNonExistingPermission() {
+    assertThat(accessSection.build().getPermission("non-existing")).isNull();
+    assertThat(accessSection.build().getPermission("non-existing")).isNull();
+  }
+
+  @Test
+  public void getPermission() {
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.upsertPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+    assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
+  }
+
+  @Test
+  public void getPermissionWithOtherCase() {
+    Permission.Builder submitPermissionLowerCase =
+        Permission.builder(Permission.SUBMIT.toLowerCase(Locale.US));
+    accessSection.addPermission(submitPermissionLowerCase);
+    assertThat(accessSection.upsertPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
+        .isEqualTo(submitPermissionLowerCase);
+  }
+
+  @Test
+  public void createMissingPermissionOnGet() {
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+
+    assertThat(accessSection.upsertPermission(Permission.SUBMIT).build())
+        .isEqualTo(Permission.create(Permission.SUBMIT));
+
+    assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
+  }
+
+  @Test
+  public void addPermission() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT))
+        .isEqualTo(submitPermission.build());
+    assertThat(accessSection.build().getPermissions())
+        .containsExactly(
+            abandonPermission.build(), rebasePermission.build(), submitPermission.build())
+        .inOrder();
+    assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
+  }
+
+  @Test
+  public void removePermission() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.remove(submitPermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.build().getPermissions())
+        .containsExactly(abandonPermission.build(), rebasePermission.build())
+        .inOrder();
+    assertThrows(NullPointerException.class, () -> accessSection.remove(null));
+  }
+
+  @Test
+  public void removePermissionByName() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    accessSection.addPermission(submitPermission);
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.removePermission(Permission.SUBMIT);
+    builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(builtAccessSection.getPermissions())
+        .containsExactly(abandonPermission.build(), rebasePermission.build())
+        .inOrder();
+
+    assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
+  }
+
+  @Test
+  public void removePermissionByNameOtherCase() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+
+    String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
+    String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
+    Permission.Builder submitPermissionLowerCase = Permission.builder(submitLowerCase);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    accessSection.addPermission(submitPermissionLowerCase);
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(submitLowerCase)).isNotNull();
+    assertThat(builtAccessSection.getPermission(submitUpperCase)).isNotNull();
+
+    accessSection.removePermission(submitUpperCase);
+    builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(submitLowerCase)).isNull();
+    assertThat(builtAccessSection.getPermission(submitUpperCase)).isNull();
+    assertThat(builtAccessSection.getPermissions())
+        .containsExactly(abandonPermission.build(), rebasePermission.build())
+        .inOrder();
+  }
+
+  @Test
+  public void testEquals() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+
+    AccessSection builtAccessSection = accessSection.build();
+    AccessSection.Builder accessSectionSamePermissionsOtherRef =
+        AccessSection.builder("refs/heads/other");
+    accessSectionSamePermissionsOtherRef.addPermission(abandonPermission);
+    accessSectionSamePermissionsOtherRef.addPermission(rebasePermission);
+    assertThat(builtAccessSection.equals(accessSectionSamePermissionsOtherRef.build())).isFalse();
+
+    AccessSection.Builder accessSectionOther = AccessSection.builder(REF_PATTERN);
+    accessSectionOther.addPermission(abandonPermission);
+    assertThat(builtAccessSection.equals(accessSectionOther.build())).isFalse();
+
+    accessSectionOther.addPermission(rebasePermission);
+    assertThat(builtAccessSection.equals(accessSectionOther.build())).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
new file mode 100644
index 0000000..3941564
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -0,0 +1,140 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import org.junit.Test;
+
+public class LabelFunctionTest {
+  private static final String LABEL_NAME = "Verified";
+  private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
+  private static final Change.Id CHANGE_ID = Change.id(100);
+  private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
+  private static final LabelType VERIFIED_LABEL = makeLabel();
+  private static final PatchSetApproval APPROVAL_2 = makeApproval(2);
+  private static final PatchSetApproval APPROVAL_1 = makeApproval(1);
+  private static final PatchSetApproval APPROVAL_0 = makeApproval(0);
+  private static final PatchSetApproval APPROVAL_M1 = makeApproval(-1);
+  private static final PatchSetApproval APPROVAL_M2 = makeApproval(-2);
+
+  @Test
+  public void checkLabelNameIsCorrect() {
+    for (LabelFunction function : LabelFunction.values()) {
+      SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
+      assertThat(myLabel.label).isEqualTo("Verified");
+    }
+  }
+
+  @Test
+  public void checkFunctionDoesNothing() {
+    checkNothingHappens(LabelFunction.NO_BLOCK);
+    checkNothingHappens(LabelFunction.NO_OP);
+    checkNothingHappens(LabelFunction.PATCH_SET_LOCK);
+    checkNothingHappens(LabelFunction.ANY_WITH_BLOCK);
+
+    checkLabelIsRequired(LabelFunction.MAX_WITH_BLOCK);
+    checkLabelIsRequired(LabelFunction.MAX_NO_BLOCK);
+  }
+
+  @Test
+  public void checkBlockWorks() {
+    checkBlockWorks(LabelFunction.ANY_WITH_BLOCK);
+    checkBlockWorks(LabelFunction.MAX_WITH_BLOCK);
+  }
+
+  @Test
+  public void checkMaxWorks() {
+    checkMaxIsEnforced(LabelFunction.MAX_NO_BLOCK);
+    checkMaxIsEnforced(LabelFunction.MAX_WITH_BLOCK);
+
+    checkMaxValidatesTheLabel(LabelFunction.MAX_NO_BLOCK);
+    checkMaxValidatesTheLabel(LabelFunction.MAX_WITH_BLOCK);
+  }
+
+  @Test
+  public void checkMaxNoBlockIgnoresMin() {
+    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_M2, APPROVAL_2, APPROVAL_M2);
+
+    SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
+  }
+
+  private static LabelType makeLabel() {
+    List<LabelValue> values = new ArrayList<>();
+    // The label text is irrelevant here, only the numerical value is used
+    values.add(LabelValue.create((short) -2, "Great job, please fix compilation."));
+    values.add(LabelValue.create((short) -1, "Really good, please make some minor changes."));
+    values.add(LabelValue.create((short) 0, "No vote."));
+    values.add(LabelValue.create((short) 1, "Closest thing perfection."));
+    values.add(LabelValue.create((short) 2, "Perfect!"));
+    return LabelType.create(LABEL_NAME, values);
+  }
+
+  private static PatchSetApproval makeApproval(int value) {
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
+        .value(value)
+        .granted(Date.from(Instant.now()))
+        .build();
+  }
+
+  private static void checkBlockWorks(LabelFunction function) {
+    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_M2, APPROVAL_2);
+
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.REJECT);
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.accountId());
+  }
+
+  private static void checkNothingHappens(LabelFunction function) {
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.MAY);
+    assertThat(myLabel.appliedBy).isNull();
+  }
+
+  private static void checkLabelIsRequired(LabelFunction function) {
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
+    assertThat(myLabel.appliedBy).isNull();
+  }
+
+  private static void checkMaxIsEnforced(LabelFunction function) {
+    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_0);
+
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
+  }
+
+  private static void checkMaxValidatesTheLabel(LabelFunction function) {
+    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_2, APPROVAL_M1);
+
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/LabelTypeTest.java b/javatests/com/google/gerrit/entities/LabelTypeTest.java
new file mode 100644
index 0000000..f31f2c9
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/LabelTypeTest.java
@@ -0,0 +1,60 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+
+public class LabelTypeTest {
+  @Test
+  public void sortLabelValues() {
+    LabelValue v0 = LabelValue.create((short) 0, "Zero");
+    LabelValue v1 = LabelValue.create((short) 1, "One");
+    LabelValue v2 = LabelValue.create((short) 2, "Two");
+    LabelType types = LabelType.create("Label", ImmutableList.of(v2, v0, v1));
+    assertThat(types.getValues()).containsExactly(v0, v1, v2).inOrder();
+  }
+
+  @Test
+  public void sortCopyValues() {
+    LabelValue v0 = LabelValue.create((short) 0, "Zero");
+    LabelValue v1 = LabelValue.create((short) 1, "One");
+    LabelValue v2 = LabelValue.create((short) 2, "Two");
+    LabelType types =
+        LabelType.builder("Label", ImmutableList.of(v2, v0, v1))
+            .setCopyValues(ImmutableList.of((short) 2, (short) 0, (short) 1))
+            .build();
+    assertThat(types.getCopyValues()).containsExactly((short) 0, (short) 1, (short) 2).inOrder();
+  }
+
+  @Test
+  public void insertMissingLabelValues() {
+    LabelValue v0 = LabelValue.create((short) 0, "Zero");
+    LabelValue v2 = LabelValue.create((short) 2, "Two");
+    LabelValue v5 = LabelValue.create((short) 5, "Five");
+    LabelType types = LabelType.create("Label", ImmutableList.of(v2, v5, v0));
+    assertThat(types.getValues())
+        .containsExactly(
+            v0,
+            LabelValue.create((short) 1, ""),
+            v2,
+            LabelValue.create((short) 3, ""),
+            LabelValue.create((short) 4, ""),
+            v5)
+        .inOrder();
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/PatchTest.java b/javatests/com/google/gerrit/entities/PatchTest.java
index 9f906a9..dce1b3e 100644
--- a/javatests/com/google/gerrit/entities/PatchTest.java
+++ b/javatests/com/google/gerrit/entities/PatchTest.java
@@ -24,6 +24,7 @@
   public void isMagic() {
     assertThat(Patch.isMagic("/COMMIT_MSG")).isTrue();
     assertThat(Patch.isMagic("/MERGE_LIST")).isTrue();
+    assertThat(Patch.isMagic("/PATCHSET_LEVEL")).isTrue();
 
     assertThat(Patch.isMagic("/COMMIT_MSG/")).isFalse();
     assertThat(Patch.isMagic("COMMIT_MSG")).isFalse();
diff --git a/javatests/com/google/gerrit/entities/PermissionRuleTest.java b/javatests/com/google/gerrit/entities/PermissionRuleTest.java
new file mode 100644
index 0000000..c2ed93f
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/PermissionRuleTest.java
@@ -0,0 +1,300 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.PermissionRule.Action;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionRuleTest {
+  private GroupReference groupReference;
+  private PermissionRule permissionRule;
+
+  @Before
+  public void setup() {
+    this.groupReference = GroupReference.create(AccountGroup.uuid("uuid"), "group");
+    this.permissionRule = PermissionRule.create(groupReference);
+  }
+
+  @Test
+  public void mergeFromAnyBlock() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isBlock()).isFalse();
+    assertThat(permissionRule2.isBlock()).isFalse();
+
+    permissionRule2 = permissionRule2.toBuilder().setBlock().build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isTrue();
+
+    permissionRule2 = permissionRule2.toBuilder().setDeny().build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isFalse();
+
+    permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isFalse();
+  }
+
+  @Test
+  public void mergeFromAnyDeny() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isDeny()).isFalse();
+    assertThat(permissionRule2.isDeny()).isFalse();
+
+    permissionRule2 = permissionRule2.toBuilder().setDeny().build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isDeny()).isTrue();
+    assertThat(permissionRule2.isDeny()).isTrue();
+
+    permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isDeny()).isTrue();
+    assertThat(permissionRule2.isDeny()).isFalse();
+  }
+
+  @Test
+  public void mergeFromAnyBatch() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getAction()).isNotEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+
+    permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isEqualTo(Action.BATCH);
+
+    permissionRule2 = permissionRule2.toBuilder().setAction(Action.ALLOW).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+  }
+
+  @Test
+  public void mergeFromAnyForce() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getForce()).isFalse();
+    assertThat(permissionRule2.getForce()).isFalse();
+
+    permissionRule2 = permissionRule2.toBuilder().setForce(true).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getForce()).isTrue();
+    assertThat(permissionRule2.getForce()).isTrue();
+
+    permissionRule2 = permissionRule2.toBuilder().setForce(false).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getForce()).isTrue();
+    assertThat(permissionRule2.getForce()).isFalse();
+  }
+
+  @Test
+  public void mergeFromMergeRange() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 =
+        PermissionRule.builder(groupReference1).setRange(-1, 2).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 =
+        PermissionRule.builder(groupReference2).setRange(-2, 1).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getMin()).isEqualTo(-2);
+    assertThat(permissionRule1.getMax()).isEqualTo(2);
+    assertThat(permissionRule2.getMin()).isEqualTo(-2);
+    assertThat(permissionRule2.getMax()).isEqualTo(1);
+  }
+
+  @Test
+  public void mergeFromGroupNotChanged() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getGroup()).isEqualTo(groupReference1);
+    assertThat(permissionRule2.getGroup()).isEqualTo(groupReference2);
+  }
+
+  @Test
+  public void asString() {
+    PermissionRule.Builder permissionRule = this.permissionRule.toBuilder();
+
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("group " + groupReference.getName());
+
+    permissionRule.setDeny();
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("deny group " + groupReference.getName());
+
+    permissionRule.setBlock();
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("block group " + groupReference.getName());
+
+    permissionRule.setAction(Action.BATCH);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("batch group " + groupReference.getName());
+
+    permissionRule.setAction(Action.INTERACTIVE);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("interactive group " + groupReference.getName());
+
+    permissionRule.setForce(true);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("interactive +force group " + groupReference.getName());
+
+    permissionRule.setAction(Action.ALLOW);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("+force group " + groupReference.getName());
+
+    permissionRule.setMax(1);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("+force +0..+1 group " + groupReference.getName());
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("+force -1..+1 group " + groupReference.getName());
+
+    assertThat(permissionRule.build().asString(false))
+        .isEqualTo("+force group " + groupReference.getName());
+  }
+
+  @Test
+  public void fromString() {
+    PermissionRule permissionRule = PermissionRule.fromString("group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("deny group A", true);
+    assertPermissionRule(permissionRule, "A", Action.DENY, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("block group A", true);
+    assertPermissionRule(permissionRule, "A", Action.BLOCK, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("batch group A", true);
+    assertPermissionRule(permissionRule, "A", Action.BATCH, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("interactive group A", true);
+    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("interactive +force group A", true);
+    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, true, 0, 0);
+
+    permissionRule = PermissionRule.fromString("+force group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+
+    permissionRule = PermissionRule.fromString("+force +0..+1 group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 1);
+
+    permissionRule = PermissionRule.fromString("+force -1..+1 group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, -1, 1);
+
+    permissionRule = PermissionRule.fromString("+force group A", false);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+  }
+
+  @Test
+  public void parseInt() {
+    assertThat(PermissionRule.parseInt("0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("+0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("-0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("1")).isEqualTo(1);
+    assertThat(PermissionRule.parseInt("+1")).isEqualTo(1);
+    assertThat(PermissionRule.parseInt("-1")).isEqualTo(-1);
+  }
+
+  @Test
+  public void testEquals() {
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule.Builder permissionRuleOther = PermissionRule.builder(groupReference2);
+    PermissionRule.Builder permissionRule = this.permissionRule.toBuilder();
+
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setGroup(groupReference);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+    permissionRule.setDeny();
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setDeny();
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+    permissionRule.setForce(true);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setForce(true);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setMin(-1);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+    permissionRule.setMax(1);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setMax(1);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+  }
+
+  private static boolean permissionRuleEquals(
+      PermissionRule.Builder r1, PermissionRule.Builder r2) {
+    return r1.build().equals(r2.build());
+  }
+
+  private void assertPermissionRule(
+      PermissionRule permissionRule,
+      String expectedGroupName,
+      Action expectedAction,
+      boolean expectedForce,
+      int expectedMin,
+      int expectedMax) {
+    assertThat(permissionRule.getGroup().getName()).isEqualTo(expectedGroupName);
+    assertThat(permissionRule.getAction()).isEqualTo(expectedAction);
+    assertThat(permissionRule.getForce()).isEqualTo(expectedForce);
+    assertThat(permissionRule.getMin()).isEqualTo(expectedMin);
+    assertThat(permissionRule.getMax()).isEqualTo(expectedMax);
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
new file mode 100644
index 0000000..2915f79
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -0,0 +1,291 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionTest {
+  private static final String PERMISSION_NAME = "foo";
+
+  private Permission.Builder permission;
+
+  @Before
+  public void setup() {
+    this.permission = Permission.builder(PERMISSION_NAME);
+  }
+
+  @Test
+  public void isPermission() {
+    assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
+    assertThat(Permission.isPermission("no-permission")).isFalse();
+
+    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void hasRange() {
+    assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
+    assertThat(Permission.hasRange("no-permission")).isFalse();
+
+    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabel() {
+    assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabel("no-permission")).isFalse();
+
+    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
+    assertThat(Permission.isLabel("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabelAs() {
+    assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabelAs("no-permission")).isFalse();
+
+    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void forLabel() {
+    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+  }
+
+  @Test
+  public void forLabelAs() {
+    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+  }
+
+  @Test
+  public void extractLabel() {
+    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
+        .isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel("Code-Review")).isNull();
+    assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
+  }
+
+  @Test
+  public void canBeOnAllProjects() {
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+  }
+
+  @Test
+  public void getName() {
+    assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
+  }
+
+  @Test
+  public void getLabel() {
+    assertThat(Permission.create(Permission.LABEL + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(Permission.create(Permission.LABEL_AS + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(Permission.create("Code-Review").getLabel()).isNull();
+    assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
+  }
+
+  @Test
+  public void exclusiveGroup() {
+    assertThat(permission.build().getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.build().getExclusiveGroup()).isTrue();
+
+    permission.setExclusiveGroup(false);
+    assertThat(permission.build().getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void noExclusiveGroupOnOwnerPermission() {
+    Permission permission = Permission.create(Permission.OWNER);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission = permission.toBuilder().setExclusiveGroup(true).build();
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void getEmptyRules() {
+    assertThat(permission.getRulesBuilders()).isNotNull();
+    assertThat(permission.getRulesBuilders()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetRules() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    assertThat(permission.getRulesBuilders())
+        .containsExactly(permissionRule1, permissionRule2)
+        .inOrder();
+
+    PermissionRule.Builder permissionRule3 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-3"), "group3"));
+    permission.modifyRules(
+        rules -> {
+          rules.clear();
+          rules.add(permissionRule3);
+        });
+    assertThat(permission.getRulesBuilders()).containsExactly(permissionRule3);
+  }
+
+  @Test
+  public void getNonExistingRule() {
+    GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
+    assertThat(permission.build().getRule(groupReference)).isNull();
+    assertThat(permission.build().getRule(groupReference)).isNull();
+  }
+
+  @Test
+  public void getRule() {
+    GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
+    PermissionRule.Builder permissionRule = PermissionRule.builder(groupReference);
+    permission.add(permissionRule);
+    assertThat(permission.build().getRule(groupReference)).isEqualTo(permissionRule.build());
+  }
+
+  @Test
+  public void addRule() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+    assertThat(permission.build().getRule(groupReference3)).isNull();
+
+    PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+    permission.add(permissionRule3);
+    assertThat(permission.build().getRule(groupReference3)).isEqualTo(permissionRule3.build());
+    assertThat(permission.build().getRules())
+        .containsExactly(permissionRule1.build(), permissionRule2.build(), permissionRule3.build())
+        .inOrder();
+  }
+
+  @Test
+  public void removeRule() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+    PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    permission.add(permissionRule3);
+    assertThat(permission.build().getRule(groupReference3)).isNotNull();
+
+    permission.remove(permissionRule3.build());
+    assertThat(permission.build().getRule(groupReference3)).isNull();
+    assertThat(permission.build().getRules())
+        .containsExactly(permissionRule1.build(), permissionRule2.build())
+        .inOrder();
+  }
+
+  @Test
+  public void removeRuleByGroupReference() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+    PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    permission.add(permissionRule3);
+    assertThat(permission.build().getRule(groupReference3)).isNotNull();
+
+    permission.removeRule(groupReference3);
+    assertThat(permission.build().getRule(groupReference3)).isNull();
+    assertThat(permission.build().getRules())
+        .containsExactly(permissionRule1.build(), permissionRule2.build())
+        .inOrder();
+  }
+
+  @Test
+  public void clearRules() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    assertThat(permission.build().getRules()).isNotEmpty();
+
+    permission.clearRules();
+    assertThat(permission.build().getRules()).isEmpty();
+  }
+
+  @Test
+  public void testEquals() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+
+    Permission.Builder permissionSameRulesOtherName = Permission.builder("bar");
+    permissionSameRulesOtherName.add(permissionRule1);
+    permissionSameRulesOtherName.add(permissionRule2);
+    assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
+
+    Permission.Builder permissionSameRulesSameNameOtherExclusiveGroup = Permission.builder("foo");
+    permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule1);
+    permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule2);
+    permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
+    assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
+
+    Permission.Builder permissionOther = Permission.builder(PERMISSION_NAME);
+    permissionOther.add(permissionRule1);
+    assertThat(permission.build().equals(permissionOther.build())).isFalse();
+
+    permissionOther.add(permissionRule2);
+    assertThat(permission.build().equals(permissionOther.build())).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/SubmitRecordTest.java b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
new file mode 100644
index 0000000..0e832f4
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
@@ -0,0 +1,70 @@
+// 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import org.junit.Test;
+
+public class SubmitRecordTest {
+  private static final SubmitRecord OK_RECORD;
+  private static final SubmitRecord FORCED_RECORD;
+  private static final SubmitRecord NOT_READY_RECORD;
+
+  static {
+    OK_RECORD = new SubmitRecord();
+    OK_RECORD.status = SubmitRecord.Status.OK;
+
+    FORCED_RECORD = new SubmitRecord();
+    FORCED_RECORD.status = SubmitRecord.Status.FORCED;
+
+    NOT_READY_RECORD = new SubmitRecord();
+    NOT_READY_RECORD.status = SubmitRecord.Status.NOT_READY;
+  }
+
+  @Test
+  public void okIfAllOkay() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    submitRecords.add(OK_RECORD);
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+  }
+
+  @Test
+  public void okWhenEmpty() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+  }
+
+  @Test
+  public void okWhenForced() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    submitRecords.add(FORCED_RECORD);
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+  }
+
+  @Test
+  public void emptyResultIfInvalid() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    submitRecords.add(NOT_READY_RECORD);
+    submitRecords.add(OK_RECORD);
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index 2896ea9..6691587 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -15,12 +15,19 @@
 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.dynamicTemplateData;
 import static com.google.gerrit.httpd.raw.IndexHtmlUtil.experimentData;
 import static com.google.gerrit.httpd.raw.IndexHtmlUtil.staticTemplateData;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.Accounts;
+import com.google.gerrit.extensions.api.config.Config;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
 import java.util.HashMap;
@@ -35,12 +42,7 @@
   public void noPathAndNoCDN() throws Exception {
     assertThat(
             staticTemplateData(
-                "http://example.com/",
-                null,
-                null,
-                new HashMap<>(),
-                IndexHtmlUtilTest::ordain,
-                null))
+                "http://example.com/", null, null, new HashMap<>(), IndexHtmlUtilTest::ordain))
         .containsExactly("canonicalPath", "", "staticResourcePath", ordain(""));
   }
 
@@ -52,8 +54,7 @@
                 null,
                 null,
                 new HashMap<>(),
-                IndexHtmlUtilTest::ordain,
-                null))
+                IndexHtmlUtilTest::ordain))
         .containsExactly("canonicalPath", "/gerrit", "staticResourcePath", ordain("/gerrit"));
   }
 
@@ -65,8 +66,7 @@
                 "http://my-cdn.com/foo/bar/",
                 null,
                 new HashMap<>(),
-                IndexHtmlUtilTest::ordain,
-                null))
+                IndexHtmlUtilTest::ordain))
         .containsExactly(
             "canonicalPath", "", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
   }
@@ -79,8 +79,7 @@
                 "http://my-cdn.com/foo/bar/",
                 null,
                 new HashMap<>(),
-                IndexHtmlUtilTest::ordain,
-                null))
+                IndexHtmlUtilTest::ordain))
         .containsExactly(
             "canonicalPath", "/gerrit", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
   }
@@ -91,51 +90,36 @@
     urlParms.put("gf", new String[0]);
     assertThat(
             staticTemplateData(
-                "http://example.com/", null, null, urlParms, IndexHtmlUtilTest::ordain, null))
+                "http://example.com/", null, null, urlParms, IndexHtmlUtilTest::ordain))
         .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(""),
+    Accounts accountsApi = mock(Accounts.class);
+    when(accountsApi.self()).thenThrow(new AuthException("user needs to be authenticated"));
+
+    Server serverApi = mock(Server.class);
+    when(serverApi.getVersion()).thenReturn("123");
+    when(serverApi.topMenus()).thenReturn(ImmutableList.of());
+    ServerInfo serverInfo = new ServerInfo();
+    serverInfo.defaultTheme = "my-default-theme";
+    when(serverApi.getInfo()).thenReturn(serverInfo);
+
+    Config configApi = mock(Config.class);
+    when(configApi.server()).thenReturn(serverApi);
+
+    GerritApi gerritApi = mock(GerritApi.class);
+    when(gerritApi.accounts()).thenReturn(accountsApi);
+    when(gerritApi.config()).thenReturn(configApi);
+
+    assertThat(dynamicTemplateData(gerritApi, "/c/project/+/123"))
+        .containsAtLeast(
             "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);
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
new file mode 100644
index 0000000..e1cccf8
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.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.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.httpd.raw.IndexPreloadingUtil.computeChangeRequestsPath;
+import static com.google.gerrit.httpd.raw.IndexPreloadingUtil.parseRequestedPage;
+
+import com.google.gerrit.httpd.raw.IndexPreloadingUtil.RequestedPage;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class IndexPreloadingUtilTest {
+
+  @Test
+  public void computeChangePath() throws Exception {
+    assertThat(computeChangeRequestsPath("/c/project/+/123", RequestedPage.CHANGE))
+        .hasValue("changes/project~123");
+
+    assertThat(computeChangeRequestsPath("/c/project/+/124/2", RequestedPage.CHANGE))
+        .hasValue("changes/project~124");
+
+    assertThat(computeChangeRequestsPath("/c/project/src/+/23", RequestedPage.CHANGE))
+        .hasValue("changes/project%2Fsrc~23");
+
+    assertThat(computeChangeRequestsPath("/q/project/src/+/23", RequestedPage.CHANGE).isPresent())
+        .isFalse();
+
+    assertThat(
+            computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", RequestedPage.CHANGE)
+                .isPresent())
+        .isFalse();
+    assertThat(computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", RequestedPage.DIFF))
+        .hasValue("changes/Scripts~232");
+  }
+
+  @Test
+  public void preloadOnlyForSelfDashboard() throws Exception {
+    assertThat(parseRequestedPage("/dashboard/self")).isEqualTo(RequestedPage.DASHBOARD);
+    assertThat(parseRequestedPage("/dashboard/1085901"))
+        .isEqualTo(RequestedPage.PAGE_WITHOUT_PRELOADING);
+    assertThat(parseRequestedPage("/dashboard/gerrit"))
+        .isEqualTo(RequestedPage.PAGE_WITHOUT_PRELOADING);
+    assertThat(parseRequestedPage("/")).isEqualTo(RequestedPage.DASHBOARD);
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index 77ab58b..ba9475f 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.joining;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -53,8 +54,15 @@
     String testCanonicalUrl = "foo-url";
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
+
+    String disabledDefault = IndexHtmlUtil.DEFAULT_EXPERIMENTS.asList().get(0);
+    org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config();
+    serverConfig.setStringList(
+        "experiments", null, "enabled", ImmutableList.of("NewFeature", "DisabledFeature"));
+    serverConfig.setStringList(
+        "experiments", null, "disabled", ImmutableList.of("DisabledFeature", disabledDefault));
     IndexServlet servlet =
-        new IndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi);
+        new IndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, serverConfig);
 
     FakeHttpServletResponse response = new FakeHttpServletResponse();
 
@@ -76,6 +84,15 @@
                 + "'\\x7b\\x22\\/config\\/server\\/version\\x22: \\x22123\\x22, "
                 + "\\x22\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
                 + "\\x22my-default-theme\\x22\\x7d, \\x22\\/config\\/server\\/top-menus\\x22: "
-                + "\\x5b\\x5d\\x7d');</script>");
+                + "\\x5b\\x5d\\x7d');");
+    String enabledDefaults =
+        IndexHtmlUtil.DEFAULT_EXPERIMENTS.stream()
+            .filter(e -> !e.equals(disabledDefault))
+            .collect(joining("\\x22,"));
+    assertThat(output)
+        .contains(
+            "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22NewFeature\\x22,\\x22"
+                + enabledDefaults
+                + "\\x22\\x5d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index 6c4c53d..3ffd61e 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -28,9 +28,9 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -98,10 +98,7 @@
                   .group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
-      // Set protocol.version=2 in target repository
-      execute(
-          ImmutableList.of("git", "config", "protocol.version", "2"),
-          sitePaths.site_path.resolve("git").resolve(project.get() + Constants.DOT_GIT).toFile());
+      setProtocolV2(project);
 
       // Retrieve HTTP url
       String url = config.getString("gerrit", null, "canonicalweburl");
@@ -217,14 +214,7 @@
       Project.NameKey allRefsVisibleProject = Project.nameKey("all-refs-visible");
       gApi.projects().create(allRefsVisibleProject.get());
 
-      // Set protocol.version=2 in target repository
-      execute(
-          ImmutableList.of("git", "config", "protocol.version", "2"),
-          sitePaths
-              .site_path
-              .resolve("git")
-              .resolve(allRefsVisibleProject.get() + Constants.DOT_GIT)
-              .toFile());
+      setProtocolV2(allRefsVisibleProject);
 
       // Set up project permission to allow reading all refs
       projectOperations
@@ -280,14 +270,7 @@
       Project.NameKey privateProject = Project.nameKey("private-project");
       gApi.projects().create(privateProject.get());
 
-      // Set protocol.version=2 in target repository
-      execute(
-          ImmutableList.of("git", "config", "protocol.version", "2"),
-          sitePaths
-              .site_path
-              .resolve("git")
-              .resolve(privateProject.get() + Constants.DOT_GIT)
-              .toFile());
+      setProtocolV2(privateProject);
 
       // Disallow general read permissions for anonymous users
       projectOperations
@@ -356,6 +339,12 @@
                 UTF_8));
   }
 
+  private void setProtocolV2(Project.NameKey projectName) throws Exception {
+    execute(
+        ImmutableList.of("git", "config", "protocol.version", "2"),
+        sitePaths.site_path.resolve("git").resolve(projectName.get() + Constants.DOT_GIT).toFile());
+  }
+
   private static void assertGitProtocolV2Refs(String commit, String out) {
     assertThat(out).containsMatch("(git|ls-remote)< version 2");
     assertThat(out).contains("refs/changes/01/1/1");
diff --git a/javatests/com/google/gerrit/json/JsonEnumMappingTest.java b/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
index dd710f9..0df0d2e 100644
--- a/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
+++ b/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.json;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
 import org.junit.Test;
 
 public class JsonEnumMappingTest {
@@ -46,27 +48,27 @@
   }
 
   @Test
-  public void mixedCaseEnumValueIsTreatedAsUnset() {
-    TestData data = gson.fromJson("{\"value\":\"oNe\"}", TestData.class);
-    assertThat(data.value).isNull();
+  public void mixedCaseEnumValueIsRejectedOnParse() {
+    assertThrows(
+        JsonSyntaxException.class, () -> gson.fromJson("{\"value\":\"oNe\"}", TestData.class));
   }
 
   @Test
-  public void lowerCaseEnumValueIsTreatedAsUnset() {
-    TestData data = gson.fromJson("{\"value\":\"one\"}", TestData.class);
-    assertThat(data.value).isNull();
+  public void lowerCaseEnumValueIsRejectedOnParse() {
+    assertThrows(
+        JsonSyntaxException.class, () -> gson.fromJson("{\"value\":\"one\"}", TestData.class));
   }
 
   @Test
-  public void notExistingEnumValueIsTreatedAsUnset() {
-    TestData data = gson.fromJson("{\"value\":\"FOUR\"}", TestData.class);
-    assertThat(data.value).isNull();
+  public void notExistingEnumValueIsRejectedOnParse() {
+    assertThrows(
+        JsonSyntaxException.class, () -> gson.fromJson("{\"value\":\"FOUR\"}", TestData.class));
   }
 
   @Test
-  public void emptyEnumValueIsTreatedAsUnset() {
-    TestData data = gson.fromJson("{\"value\":\"\"}", TestData.class);
-    assertThat(data.value).isNull();
+  public void emptyEnumValueIsRejectedOnParse() {
+    assertThrows(
+        JsonSyntaxException.class, () -> gson.fromJson("{\"value\":\"\"}", TestData.class));
   }
 
   private static class TestData {
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index f3c8671..b22b8ad 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -17,7 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -37,7 +39,7 @@
   }
 
   protected static void assertInlineComment(
-      String message, MailComment comment, Comment inReplyTo) {
+      String message, MailComment comment, HumanComment inReplyTo) {
     assertThat(comment.fileName).isNull();
     assertThat(comment.message).isEqualTo(message);
     assertThat(comment.inReplyTo.key).isEqualTo(inReplyTo.key);
@@ -51,9 +53,9 @@
     assertThat(comment.type).isEqualTo(MailComment.CommentType.FILE_COMMENT);
   }
 
-  protected static Comment newComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
+  protected static HumanComment newComment(String uuid, String file, String message, int line) {
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
             new Timestamp(0L),
@@ -65,9 +67,10 @@
     return c;
   }
 
-  protected static Comment newRangeComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
+  protected static HumanComment newRangeComment(
+      String uuid, String file, String message, int line) {
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
             new Timestamp(0L),
@@ -91,8 +94,8 @@
   }
 
   /** Returns a List of default comments for testing. */
-  protected static List<Comment> defaultComments() {
-    List<Comment> comments = new ArrayList<>();
+  protected static List<HumanComment> defaultComments() {
+    List<HumanComment> comments = new ArrayList<>();
     comments.add(newComment("c1", "gerrit-server/test.txt", "comment", 0));
     comments.add(newComment("c2", "gerrit-server/test.txt", "comment", 2));
     comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 3));
diff --git a/javatests/com/google/gerrit/mail/AddressTest.java b/javatests/com/google/gerrit/mail/AddressTest.java
index 8addcf8..232b8d1 100644
--- a/javatests/com/google/gerrit/mail/AddressTest.java
+++ b/javatests/com/google/gerrit/mail/AddressTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.gerrit.entities.Address;
 import org.junit.Test;
 
 public class AddressTest {
diff --git a/javatests/com/google/gerrit/mail/HtmlParserTest.java b/javatests/com/google/gerrit/mail/HtmlParserTest.java
index 345cb05..d661278 100644
--- a/javatests/com/google/gerrit/mail/HtmlParserTest.java
+++ b/javatests/com/google/gerrit/mail/HtmlParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.List;
 import org.junit.Ignore;
 import org.junit.Test;
@@ -31,7 +31,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.htmlContent(newHtmlBody("Looks good to me", null, null, null, null, null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
 
     assertThat(parsedComments).hasSize(1);
@@ -52,7 +52,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
 
     assertThat(parsedComments).hasSize(1);
@@ -73,7 +73,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -96,7 +96,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -121,7 +121,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -135,7 +135,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.htmlContent(newHtmlBody(null, null, null, null, null, null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).isEmpty();
@@ -148,7 +148,7 @@
         newHtmlBody(
             null, null, null, "Also have a comment here.", "This is a nice file", null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
@@ -164,7 +164,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.htmlContent(newHtmlBody(htmlMessage, null, null, htmlMessage, htmlMessage, null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
diff --git a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
index 296d1a1..7e3edab 100644
--- a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
+++ b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.gerrit.entities.Address;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/TextParserTest.java b/javatests/com/google/gerrit/mail/TextParserTest.java
index 00d5b41..f1d6179 100644
--- a/javatests/com/google/gerrit/mail/TextParserTest.java
+++ b/javatests/com/google/gerrit/mail/TextParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.List;
 import org.junit.Test;
 
@@ -39,7 +39,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.textContent("Looks good to me\n" + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(1);
@@ -60,7 +60,7 @@
                 null)
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -83,7 +83,7 @@
                 null)
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -97,7 +97,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.textContent(newPlaintextBody(null, null, null, null, null, null, null) + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).isEmpty();
@@ -111,7 +111,7 @@
                 null, null, null, "Also have a comment here.", "This is a nice file", null, null)
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
@@ -134,7 +134,7 @@
                 + quotedFooter)
             .replace("> ", ">> "));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -157,7 +157,7 @@
                 "Comment in reply to file comment")
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
diff --git a/javatests/com/google/gerrit/mail/data/AttachmentMessage.java b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
index aea59ba..b39e3be 100644
--- a/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
+++ b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
index 957ee6e..92ba97c 100644
--- a/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
+++ b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
index e5e2ed8..7cbf9c0 100644
--- a/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
+++ b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
index e183a37..86a0b56 100644
--- a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
+++ b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
@@ -13,7 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
@@ -45,7 +45,8 @@
   public int[] rawChars() {
     int[] arr = new int[raw.length()];
     int i = 0;
-    for (char c : raw.toCharArray()) {
+    for (int j = 0; j < raw.length(); j++) {
+      char c = raw.charAt(j);
       arr[i++] = c;
     }
     return arr;
diff --git a/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
index ac739c8..94c9d42 100644
--- a/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
+++ b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
index 3f8e62f..20d8076 100644
--- a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
+++ b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/server/account/AccountCacheTest.java b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
index 5e75fe5..a2aa40b 100644
--- a/javatests/com/google/gerrit/server/account/AccountCacheTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
@@ -19,6 +19,7 @@
 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.NotifyConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.CachedPreferences;
@@ -103,7 +104,7 @@
     CachedAccountDetails original =
         CachedAccountDetails.create(
             ACCOUNT,
-            ImmutableMap.of(key, ImmutableSet.of(ProjectWatches.NotifyType.ALL_COMMENTS)),
+            ImmutableMap.of(key, ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS)),
             CachedPreferences.fromString(""));
 
     byte[] serialized = SERIALIZER.serialize(original);
@@ -127,7 +128,7 @@
     CachedAccountDetails original =
         CachedAccountDetails.create(
             ACCOUNT,
-            ImmutableMap.of(key, ImmutableSet.of(ProjectWatches.NotifyType.ALL_COMMENTS)),
+            ImmutableMap.of(key, ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS)),
             CachedPreferences.fromString(""));
 
     byte[] serialized = SERIALIZER.serialize(original);
diff --git a/javatests/com/google/gerrit/server/account/DestinationListTest.java b/javatests/com/google/gerrit/server/account/DestinationListTest.java
index 4188f39..6fcf75c 100644
--- a/javatests/com/google/gerrit/server/account/DestinationListTest.java
+++ b/javatests/com/google/gerrit/server/account/DestinationListTest.java
@@ -132,7 +132,7 @@
     List<ValidationError> errors = new ArrayList<>();
     new DestinationList().parseLabel(LABEL, L_BAD, errors::add);
     assertThat(errors)
-        .containsExactly(new ValidationError("destinationslabel", 1, "missing tab delimiter"));
+        .containsExactly(ValidationError.create("destinationslabel", 1, "missing tab delimiter"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/QueryListTest.java b/javatests/com/google/gerrit/server/account/QueryListTest.java
index 7d491c9..74ce907 100644
--- a/javatests/com/google/gerrit/server/account/QueryListTest.java
+++ b/javatests/com/google/gerrit/server/account/QueryListTest.java
@@ -101,7 +101,8 @@
   public void testParseBad() throws Exception {
     List<ValidationError> errors = new ArrayList<>();
     assertThat(QueryList.parse(L_BAD, errors::add).asText()).isNull();
-    assertThat(errors).containsExactly(new ValidationError("queries", 1, "missing tab delimiter"));
+    assertThat(errors)
+        .containsExactly(ValidationError.create("queries", 1, "missing tab delimiter"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/WatchConfigTest.java b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
index 95dbbde..7d36b94 100644
--- a/javatests/com/google/gerrit/server/account/WatchConfigTest.java
+++ b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
@@ -19,8 +19,8 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.NotifyValue;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.git.ValidationError;
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
index 586f1bc..64fa74f 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
@@ -71,6 +72,23 @@
     assertThat(s.deserialize(serializedWithEmptyString)).isEqualTo(tokenWithNull);
   }
 
+  @Test
+  public void serializeAndDeserializeBackAccountId() {
+    OAuthTokenCache.AccountIdSerializer serializer = OAuthTokenCache.AccountIdSerializer.INSTANCE;
+
+    Account.Id id = Account.id(1234);
+    assertThat(serializer.deserialize(serializer.serialize(id))).isEqualTo(id);
+  }
+
+  // Anonymous classes can break some cache implementations that try to parse the
+  // serializer class name and expect a well-defined class name: test that
+  // OAuthTokenCache.AccountIdSerializer is not an anonymous class.
+  @Test
+  public void accountIdSerializerIsNotAnAnonymousClass() {
+    assertThat(OAuthTokenCache.AccountIdSerializer.INSTANCE.getDeclaringClass().getSimpleName())
+        .isNotEmpty();
+  }
+
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void oAuthTokenFields() throws Exception {
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index bb6ea30..c708e09 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -1,8 +1,9 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 junit_tests(
     name = "tests",
-    srcs = glob(["*.java"]),
+    srcs = glob(["*Test.java"]),
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
@@ -13,3 +14,9 @@
         "@servlet-api//jar",
     ],
 )
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_cache",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index 0453fa1..1bb9784 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -22,12 +22,16 @@
 import org.junit.Test;
 
 public class PerThreadCacheTest {
+
+  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void key_respectsClass() {
     assertThat(PerThreadCache.Key.create(String.class))
         .isEqualTo(PerThreadCache.Key.create(String.class));
     assertThat(PerThreadCache.Key.create(String.class))
-        .isNotEqualTo(PerThreadCache.Key.create(Integer.class));
+        .isNotEqualTo(
+            /* expected: Key<String>, actual: Key<Integer> */ PerThreadCache.Key.create(
+                Integer.class));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
new file mode 100644
index 0000000..d8c6fe2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.server.ModuleImpl;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class PersistentCacheFactoryIT extends AbstractDaemonTest {
+
+  @Inject PersistentCacheFactory persistentCacheFactory;
+
+  @ModuleImpl(name = CacheModule.PERSISTENT_MODULE)
+  public static class Module extends AbstractModule {
+
+    @Override
+    protected void configure() {
+      bind(PersistentCacheFactory.class).to(TestCacheFactory.class);
+    }
+  }
+
+  @Override
+  public com.google.inject.Module createModule() {
+    return new Module();
+  }
+
+  @Test
+  public void shouldH2PersistentCacheBeReplaceableByADifferentCacheImplementation() {
+    assertThat(persistentCacheFactory).isInstanceOf(TestCacheFactory.class);
+  }
+
+  public static class TestCacheFactory implements PersistentCacheFactory {
+
+    private final MemoryCacheFactory memoryCacheFactory;
+
+    @Inject
+    TestCacheFactory(MemoryCacheFactory memoryCacheFactory) {
+      this.memoryCacheFactory = memoryCacheFactory;
+    }
+
+    @Override
+    public <K, V> com.google.common.cache.Cache<K, V> build(
+        PersistentCacheDef<K, V> def, CacheBackend backend) {
+      return memoryCacheFactory.build(def, backend);
+    }
+
+    @Override
+    public <K, V> LoadingCache<K, V> build(
+        PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
+      return memoryCacheFactory.build(def, loader, backend);
+    }
+
+    @Override
+    public void onStop(String plugin) {}
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 98f1b0e..438990c 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -6,10 +6,12 @@
     deps = [
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
         "//lib/guice",
+        "//lib/mockito",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 69c2799..3ade4d0 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -16,16 +16,27 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.TypeLiteral;
+import java.time.Duration;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.Nullable;
 import org.junit.Test;
 
 public class H2CacheTest {
@@ -38,23 +49,31 @@
   }
 
   private static H2CacheImpl<String, String> newH2CacheImpl(
-      int id, Cache<String, ValueHolder<String>> mem, int version) {
-    SqlStore<String, String> store =
-        new SqlStore<>(
-            "jdbc:h2:mem:Test_" + id,
-            KEY_TYPE,
-            StringCacheSerializer.INSTANCE,
-            StringCacheSerializer.INSTANCE,
-            version,
-            1 << 20,
-            null);
+      SqlStore<String, String> store, Cache<String, ValueHolder<String>> mem) {
     return new H2CacheImpl<>(MoreExecutors.directExecutor(), store, KEY_TYPE, mem);
   }
 
+  private static SqlStore<String, String> newStore(
+      int id,
+      int version,
+      @Nullable Duration expireAfterWrite,
+      @Nullable Duration refreshAfterWrite) {
+    return new SqlStore<>(
+        "jdbc:h2:mem:Test_" + id,
+        KEY_TYPE,
+        StringCacheSerializer.INSTANCE,
+        StringCacheSerializer.INSTANCE,
+        version,
+        1 << 20,
+        expireAfterWrite,
+        refreshAfterWrite);
+  }
+
   @Test
   public void get() throws ExecutionException {
     Cache<String, ValueHolder<String>> mem = CacheBuilder.newBuilder().build();
-    H2CacheImpl<String, String> impl = newH2CacheImpl(nextDbId(), mem, DEFAULT_VERSION);
+    H2CacheImpl<String, String> impl =
+        newH2CacheImpl(newStore(nextDbId(), DEFAULT_VERSION, null, null), mem);
 
     assertThat(impl.getIfPresent("foo")).isNull();
 
@@ -94,11 +113,12 @@
   }
 
   @Test
-  public void version() throws Exception {
+  public void version() {
     int id = nextDbId();
-    H2CacheImpl<String, String> oldImpl = newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION);
+    H2CacheImpl<String, String> oldImpl =
+        newH2CacheImpl(newStore(id, DEFAULT_VERSION, null, null), disableMemCache());
     H2CacheImpl<String, String> newImpl =
-        newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION + 1);
+        newH2CacheImpl(newStore(id, DEFAULT_VERSION + 1, null, null), disableMemCache());
 
     assertThat(oldImpl.diskStats().space()).isEqualTo(0);
     assertThat(newImpl.diskStats().space()).isEqualTo(0);
@@ -124,6 +144,58 @@
     assertThat(oldImpl.getIfPresent("key")).isNull();
   }
 
+  @Test
+  public void refreshAfterWrite_triggeredWhenConfigured() throws Exception {
+    SqlStore<String, String> store =
+        newStore(nextDbId(), DEFAULT_VERSION, null, Duration.ofMillis(10));
+
+    // This is the loader that we configure for the cache when calling .loader(...)
+    @SuppressWarnings("unchecked")
+    CacheLoader<String, String> baseLoader = mock(CacheLoader.class);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // We wrap baseLoader just like H2CacheFactory is wrapping it. The wrapped version will call out
+    // to the store for refreshing values.
+    H2CacheImpl.Loader<String, String> wrappedLoader =
+        new H2CacheImpl.Loader<>(MoreExecutors.directExecutor(), store, baseLoader);
+    // memCache is the in-memory variant of the cache. Its loader is wrappedLoader which will call
+    // out to the store to save or delete cached values.
+    LoadingCache<String, ValueHolder<String>> memCache =
+        CacheBuilder.newBuilder().maximumSize(10).build(wrappedLoader);
+
+    // h2Cache puts it all together
+    H2CacheImpl<String, String> h2Cache = newH2CacheImpl(store, memCache);
+
+    // Initial load and cache retrieval do not trigger refresh
+    // This works because we use a directExecutor() for refreshes
+    TimeUtil.setCurrentMillisSupplier(() -> 0);
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    verify(baseLoader).load("foo");
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    verifyNoMoreInteractions(baseLoader);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // Load after refresh duration returns old value, triggers refresh and returns new value
+    TimeUtil.setCurrentMillisSupplier(() -> 11);
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    assertThat(h2Cache.get("foo")).isEqualTo("reload:foo");
+    verify(baseLoader).reload("foo", "load:foo");
+    verifyNoMoreInteractions(baseLoader);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // Refreshed value was persisted
+    memCache.invalidateAll(); // Invalidates only the memcache, not the store.
+    assertThat(h2Cache.getIfPresent("foo")).isEqualTo("reload:foo");
+  }
+
+  @SuppressWarnings("unchecked")
+  private static void resetLoaderAndAnswerLoadAndRefreshCalls(CacheLoader<String, String> loader)
+      throws Exception {
+    reset(loader);
+    when(loader.load("foo")).thenReturn("load:foo");
+    when(loader.reload("foo", "load:foo")).thenReturn(Futures.immediateFuture("reload:foo"));
+  }
+
   private static <K, V> Cache<K, ValueHolder<V>> disableMemCache() {
     return CacheBuilder.newBuilder().maximumSize(0).build();
   }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.java
new file mode 100644
index 0000000..40a8105
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.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.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.AccessSectionSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.AccessSectionSerializer.serialize;
+
+import com.google.gerrit.entities.AccessSection;
+import org.junit.Test;
+
+public class AccessSectionSerializerTest {
+  static final AccessSection ALL_VALUES_SET =
+      AccessSection.builder("refs/test")
+          .addPermission(PermissionSerializerTest.ALL_VALUES_SET.toBuilder())
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    AccessSection autoValue = AccessSection.builder("refs/test").build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/AddressSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/AddressSerializerTest.java
new file mode 100644
index 0000000..4f080a7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/AddressSerializerTest.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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.AddressSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.AddressSerializer.serialize;
+
+import com.google.gerrit.entities.Address;
+import org.junit.Test;
+
+public class AddressSerializerTest {
+  @Test
+  public void roundTrip() {
+    Address autoValue = Address.create("Jane Doe", "jdoe@example.com");
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    Address autoValue = Address.create("jdoe@example.com");
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
new file mode 100644
index 0000000..7fe73d5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -0,0 +1,22 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/cache/serialize/entities",
+        "//java/com/google/gerrit/server/cache/testing",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+        "//proto/testing:test_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.java
new file mode 100644
index 0000000..10b905a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.BranchOrderSectionSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.BranchOrderSectionSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchOrderSection;
+import org.junit.Test;
+
+public class BranchOrderSectionSerializerTest {
+  static final BranchOrderSection ALL_VALUES_SET =
+      BranchOrderSection.create(ImmutableList.of("master", "stable"));
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
new file mode 100644
index 0000000..c7e09dc
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.entities.PermissionRule;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class CachedProjectConfigSerializerTest {
+  static final CachedProjectConfig MINIMAL_VALUES_SET =
+      CachedProjectConfig.builder()
+          .setProject(ProjectSerializerTest.ALL_VALUES_SET)
+          .setMimeTypes(
+              ConfiguredMimeTypes.create(
+                  ImmutableList.of(new ConfiguredMimeTypes.ReType("type", "pattern"))))
+          .setAccountsSection(
+              AccountsSection.create(
+                  ImmutableList.of(
+                      PermissionRule.create(GroupReferenceSerializerTest.ALL_VALUES_SET))))
+          .setMaxObjectSizeLimit(123)
+          .setCheckReceivedObjects(true)
+          .build();
+
+  static final CachedProjectConfig ALL_VALUES_SET =
+      MINIMAL_VALUES_SET
+          .toBuilder()
+          .addGroup(GroupReferenceSerializerTest.ALL_VALUES_SET)
+          .addAccessSection(AccessSectionSerializerTest.ALL_VALUES_SET)
+          .setBranchOrderSection(Optional.of(BranchOrderSectionSerializerTest.ALL_VALUES_SET))
+          .addNotifySection(NotifyConfigSerializerTest.ALL_VALUES_SET)
+          .addLabelSection(LabelTypeSerializerTest.ALL_VALUES_SET)
+          .addSubscribeSection(SubscribeSectionSerializerTest.ALL_VALUES_SET)
+          .addCommentLinkSection(StoredCommentLinkInfoSerializerTest.HTML_ONLY)
+          .setRevision(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
+          .setRulesId(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
+          .setExtensionPanelSections(ImmutableMap.of("key1", ImmutableList.of("val1", "val2")))
+          .addPluginConfig("foo-plugin", "[plugin \"foo-plugin\"]\n\tkey = value")
+          .addProjectLevelConfig("foo-plugin.config", "[section]\n\tkey = value")
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    assertThat(deserialize(serialize(MINIMAL_VALUES_SET))).isEqualTo(MINIMAL_VALUES_SET);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializerTest.java
new file mode 100644
index 0000000..f0e4932
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializerTest.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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.ConfiguredMimeTypeSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.ConfiguredMimeTypeSerializer.serialize;
+
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import org.junit.Test;
+
+public class ConfiguredMimeTypeSerializerTest {
+  @Test
+  public void reType_roundTrip() {
+    ConfiguredMimeTypes.ReType value = new ConfiguredMimeTypes.ReType("type", "pattern");
+    assertThat(deserialize(serialize(value))).isEqualTo(value);
+  }
+
+  @Test
+  public void fnType_roundTrip() throws Exception {
+    ConfiguredMimeTypes.FnType value = new ConfiguredMimeTypes.FnType("type", "pattern");
+    assertThat(deserialize(serialize(value))).isEqualTo(value);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.java
new file mode 100644
index 0000000..99e3c07
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.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.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.ContributorAgreementSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.ContributorAgreementSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
+import org.junit.Test;
+
+public class ContributorAgreementSerializerTest {
+  @Test
+  public void roundTrip() {
+    ContributorAgreement autoValue =
+        ContributorAgreement.builder("name")
+            .setDescription("desc")
+            .setAgreementUrl("url")
+            .setAutoVerify(GroupReference.create("auto-verify"))
+            .setAccepted(
+                ImmutableList.of(
+                    PermissionRule.create(GroupReference.create("accepted1")),
+                    PermissionRule.create(GroupReference.create("accepted2"))))
+            .setExcludeProjectsRegexes(ImmutableList.of("refs/*"))
+            .setMatchProjectsRegexes(ImmutableList.of("refs/heads/*"))
+            .build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    ContributorAgreement autoValue =
+        ContributorAgreement.builder("name")
+            .setAccepted(
+                ImmutableList.of(
+                    PermissionRule.create(GroupReference.create("accepted1")),
+                    PermissionRule.create(GroupReference.create("accepted2"))))
+            .build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.java
new file mode 100644
index 0000000..f366337
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.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.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.GroupReferenceSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.GroupReferenceSerializer.serialize;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import org.junit.Test;
+
+public class GroupReferenceSerializerTest {
+  static final GroupReference ALL_VALUES_SET =
+      GroupReference.create(AccountGroup.uuid("uuid"), "name");
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    GroupReference groupReferenceAutoValue = GroupReference.create("name");
+    assertThat(deserialize(serialize(groupReferenceAutoValue))).isEqualTo(groupReferenceAutoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
new file mode 100644
index 0000000..a82fdb9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.LabelTypeSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.LabelTypeSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import org.junit.Test;
+
+public class LabelTypeSerializerTest {
+  static final LabelType ALL_VALUES_SET =
+      LabelType.builder(
+              "name",
+              ImmutableList.of(
+                  LabelValue.create((short) 0, "no vote"),
+                  LabelValue.create((short) 1, "approved")))
+          .setCanOverride(!LabelType.DEF_CAN_OVERRIDE)
+          .setAllowPostSubmit(!LabelType.DEF_ALLOW_POST_SUBMIT)
+          .setIgnoreSelfApproval(!LabelType.DEF_IGNORE_SELF_APPROVAL)
+          .setRefPatterns(ImmutableList.of("refs/heads/*", "refs/tags/*"))
+          .setDefaultValue((short) 1)
+          .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
+          .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
+          .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
+          .setCopyAllScoresOnMergeFirstParentUpdate(
+              !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+          .setCopyAllScoresOnTrivialRebase(!LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+          .setCopyAllScoresIfNoCodeChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+          .setCopyAllScoresIfNoChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
+          .setCopyValues(ImmutableList.of((short) 0, (short) 1))
+          .setMaxNegative((short) -1)
+          .setMaxPositive((short) 1)
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    LabelType autoValue = ALL_VALUES_SET.toBuilder().setRefPatterns(null).build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializerTest.java
new file mode 100644
index 0000000..7e3abbd
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializerTest.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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.LabelValueSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.LabelValueSerializer.serialize;
+
+import com.google.gerrit.entities.LabelValue;
+import org.junit.Test;
+
+public class LabelValueSerializerTest {
+  @Test
+  public void roundTrip() {
+    LabelValue autoValue = LabelValue.create((short) 123, "Approved!");
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java
new file mode 100644
index 0000000..5052dfc
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.NotifyConfigSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.NotifyConfigSerializer.serialize;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.NotifyConfig;
+import org.junit.Test;
+
+public class NotifyConfigSerializerTest {
+  static final NotifyConfig ALL_VALUES_SET =
+      NotifyConfig.builder()
+          .setName("foo-bar")
+          .addAddress(Address.create("address@example.com"))
+          .addGroup(GroupReference.create("group-uuid"))
+          .setHeader(NotifyConfig.Header.CC)
+          .setFilter("filter")
+          .setNotify(ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS))
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    NotifyConfig autoValue = NotifyConfig.builder().setName("foo-bar").build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java
new file mode 100644
index 0000000..a3e6b93
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.PermissionRuleSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.PermissionRuleSerializer.serialize;
+
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
+import org.junit.Test;
+
+public class PermissionRuleSerializerTest {
+  @Test
+  public void roundTrip() {
+    PermissionRule permissionRuleAutoValue =
+        PermissionRule.builder(GroupReference.create("name"))
+            .setAction(PermissionRule.Action.BATCH)
+            .setForce(!PermissionRule.DEF_FORCE)
+            .setMax(321)
+            .setMin(123)
+            .build();
+    assertThat(deserialize(serialize(permissionRuleAutoValue))).isEqualTo(permissionRuleAutoValue);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    PermissionRule permissionRuleAutoValue = PermissionRule.create(GroupReference.create("name"));
+    assertThat(deserialize(serialize(permissionRuleAutoValue))).isEqualTo(permissionRuleAutoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java
new file mode 100644
index 0000000..2007bca
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.PermissionSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.PermissionSerializer.serialize;
+
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import org.junit.Test;
+
+public class PermissionSerializerTest {
+  static final Permission ALL_VALUES_SET =
+      Permission.builder(Permission.ABANDON)
+          .setExclusiveGroup(!Permission.DEF_EXCLUSIVE_GROUP)
+          .add(PermissionRule.builder(GroupReference.create("group")))
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    Permission permission = Permission.builder(Permission.ABANDON).build();
+    assertThat(deserialize(serialize(permission))).isEqualTo(permission);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
new file mode 100644
index 0000000..29fd5ed
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.ProjectSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.ProjectSerializer.serialize;
+
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import org.junit.Test;
+
+public class ProjectSerializerTest {
+  static final Project ALL_VALUES_SET =
+      Project.builder(Project.nameKey("test"))
+          .setDescription("desc")
+          .setSubmitType(SubmitType.FAST_FORWARD_ONLY)
+          .setState(ProjectState.HIDDEN)
+          .setParent(Project.nameKey("parent"))
+          .setMaxObjectSizeLimit("11K")
+          .setDefaultDashboard("dashboard1")
+          .setLocalDefaultDashboard("dashboard2")
+          .setConfigRefState("1337")
+          .setBooleanConfig(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE)
+          .setBooleanConfig(
+              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+              InheritableBoolean.INHERIT)
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    Project projectAutoValue =
+        Project.builder(Project.nameKey("test"))
+            .setSubmitType(SubmitType.FAST_FORWARD_ONLY)
+            .setState(ProjectState.HIDDEN)
+            .build();
+
+    assertThat(deserialize(serialize(projectAutoValue))).isEqualTo(projectAutoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
new file mode 100644
index 0000000..e293493
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.StoredCommentLinkInfoSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.StoredCommentLinkInfoSerializer.serialize;
+
+import com.google.gerrit.entities.StoredCommentLinkInfo;
+import org.junit.Test;
+
+public class StoredCommentLinkInfoSerializerTest {
+  static final StoredCommentLinkInfo HTML_ONLY =
+      StoredCommentLinkInfo.builder("name")
+          .setEnabled(true)
+          .setHtml("<p>html")
+          .setMatch("*")
+          .build();
+
+  @Test
+  public void htmlOnly_roundTrip() {
+    assertThat(deserialize(serialize(HTML_ONLY))).isEqualTo(HTML_ONLY);
+  }
+
+  @Test
+  public void linkOnly_roundTrip() {
+    StoredCommentLinkInfo autoValue =
+        StoredCommentLinkInfo.builder("name")
+            .setEnabled(true)
+            .setLink("<p>html")
+            .setMatch("*")
+            .build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+
+  @Test
+  public void overrideOnly_roundTrip() {
+    StoredCommentLinkInfo autoValue =
+        StoredCommentLinkInfo.builder("name")
+            .setEnabled(true)
+            .setOverrideOnly(true)
+            .setLink("<p>html")
+            .setMatch("*")
+            .build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+
+  @Test
+  public void nullEnabled_roundTrip() {
+    StoredCommentLinkInfo sourceAutoValue =
+        StoredCommentLinkInfo.builder("name").setLink("<p>html").setMatch("*").build();
+
+    StoredCommentLinkInfo storedAutoValue =
+        StoredCommentLinkInfo.builder("name")
+            .setLink("<p>html")
+            .setMatch("*")
+            .setEnabled(true)
+            .build();
+
+    assertThat(deserialize(serialize(sourceAutoValue))).isEqualTo(storedAutoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java
new file mode 100644
index 0000000..1648eca
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.SubscribeSectionSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.SubscribeSectionSerializer.serialize;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubscribeSection;
+import org.junit.Test;
+
+public class SubscribeSectionSerializerTest {
+  static final SubscribeSection ALL_VALUES_SET =
+      SubscribeSection.builder(Project.nameKey("project"))
+          .addMultiMatchRefSpec("multi")
+          .addMultiMatchRefSpec("multi2")
+          .addMatchingRefSpec("matching1")
+          .addMatchingRefSpec("matching2")
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadTest.java b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
new file mode 100644
index 0000000..dc46e48
--- /dev/null
+++ b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Comment.Key;
+import com.google.gerrit.entities.HumanComment;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class CommentThreadTest {
+
+  @Test
+  public void threadMustContainAtLeastOneComment() {
+    assertThrows(IllegalStateException.class, () -> CommentThread.builder().build());
+  }
+
+  @Test
+  public void threadCanBeUnresolved() {
+    HumanComment root = unresolved(createComment("root"));
+    CommentThread<Comment> commentThread = CommentThread.builder().addComment(root).build();
+
+    assertThat(commentThread.unresolved()).isTrue();
+  }
+
+  @Test
+  public void threadCanBeResolved() {
+    HumanComment root = resolved(createComment("root"));
+    CommentThread<Comment> commentThread = CommentThread.builder().addComment(root).build();
+
+    assertThat(commentThread.unresolved()).isFalse();
+  }
+
+  @Test
+  public void lastCommentInThreadDeterminesUnresolvedStatus() {
+    HumanComment root = resolved(createComment("root"));
+    HumanComment child = unresolved(createComment("child"));
+    CommentThread<Comment> commentThread =
+        CommentThread.builder().addComment(root).addComment(child).build();
+
+    assertThat(commentThread.unresolved()).isTrue();
+  }
+
+  private static HumanComment createComment(String commentUuid) {
+    return new HumanComment(
+        new Key(commentUuid, "myFile", 1),
+        Account.id(100),
+        new Timestamp(1234),
+        (short) 1,
+        "Comment text",
+        "serverId",
+        true);
+  }
+
+  private static HumanComment resolved(HumanComment comment) {
+    comment.unresolved = false;
+    return comment;
+  }
+
+  private static HumanComment unresolved(HumanComment comment) {
+    comment.unresolved = true;
+    return comment;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
new file mode 100644
index 0000000..56566d3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
@@ -0,0 +1,285 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment.Key;
+import com.google.gerrit.entities.HumanComment;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class CommentThreadsTest {
+
+  @Test
+  public void threadsAreEmptyWhenNoCommentsAreProvided() {
+    ImmutableList<HumanComment> comments = ImmutableList.of();
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads = ImmutableSet.of();
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void threadsCanBeCreatedFromSingleRoot() {
+    HumanComment root = createComment("root");
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(root);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads = ImmutableSet.of(toThread(root));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void threadsCanBeCreatedFromUnorderedComments() {
+    HumanComment root = createComment("root");
+    HumanComment child1 = asReply(createComment("child1"), "root");
+    HumanComment child2 = asReply(createComment("child2"), "child1");
+    HumanComment child3 = asReply(createComment("child3"), "child2");
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(child2, child1, root, child3);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(root, child1, child2, child3));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void childWithNotAvailableParentIsAssumedToBeRoot() {
+    HumanComment child1 = asReply(createComment("child1"), "root");
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(child1);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads = ImmutableSet.of(toThread(child1));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void threadsIgnoreDuplicateRoots() {
+    HumanComment root = createComment("root");
+    HumanComment child1 = asReply(createComment("child1"), "root");
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(root, root, child1);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(root, child1));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void threadsIgnoreDuplicateChildren() {
+    HumanComment root = createComment("root");
+    HumanComment child1 = asReply(createComment("child1"), "root");
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(root, child1, child1);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(root, child1));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void commentsAreOrderedIntoCorrectThreads() {
+    HumanComment thread1Root = createComment("thread1Root");
+    HumanComment thread1Child1 = asReply(createComment("thread1Child1"), "thread1Root");
+    HumanComment thread1Child2 = asReply(createComment("thread1Child2"), "thread1Child1");
+    HumanComment thread2Root = createComment("thread2Root");
+    HumanComment thread2Child1 = asReply(createComment("thread2Child1"), "thread2Root");
+
+    ImmutableList<HumanComment> comments =
+        ImmutableList.of(thread2Root, thread1Child2, thread1Child1, thread1Root, thread2Child1);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(
+            toThread(thread1Root, thread1Child1, thread1Child2),
+            toThread(thread2Root, thread2Child1));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void branchedThreadsAreFlattenedAccordingToDate() {
+    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
+    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
+    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+    HumanComment sibling1Child =
+        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+    HumanComment sibling2Child =
+        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+
+    ImmutableList<HumanComment> comments =
+        ImmutableList.of(sibling2, sibling2Child, sibling1, sibling1Child, root);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(root, sibling1, sibling2, sibling1Child, sibling2Child));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void threadsConsiderParentRelationshipStrongerThanDate() {
+    HumanComment root = writtenOn(createComment("root"), new Timestamp(3));
+    HumanComment child1 = writtenOn(asReply(createComment("child1"), "root"), new Timestamp(2));
+    HumanComment child2 = writtenOn(asReply(createComment("child2"), "child1"), new Timestamp(1));
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(child2, child1, root);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(root, child1, child2));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void threadsFallBackToUuidOrderIfParentAndDateAreTheSame() {
+    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
+    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
+    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(2));
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(sibling2, sibling1, root);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreads();
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(root, sibling1, sibling2));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void specificThreadsCanBeRequestedByTheirReply() {
+    HumanComment thread1Root = createComment("thread1Root");
+    HumanComment thread2Root = createComment("thread2Root");
+
+    HumanComment thread1Reply = asReply(createComment("thread1Reply"), "thread1Root");
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(thread1Root, thread2Root, thread1Reply);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(thread1Reply));
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(thread1Root, thread1Reply));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void requestedThreadsDoNotNeedToContainReply() {
+    HumanComment thread1Root = createComment("thread1Root");
+    HumanComment thread2Root = createComment("thread2Root");
+
+    HumanComment thread1Reply = asReply(createComment("thread1Reply"), "thread1Root");
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(thread1Root, thread2Root);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(thread1Reply));
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(thread1Root));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void completeThreadCanBeRequestedByReplyToRootComment() {
+    HumanComment root = createComment("root");
+    HumanComment child = asReply(createComment("child"), "root");
+
+    HumanComment reply = asReply(createComment("reply"), "root");
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(root, child);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(reply));
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(root, child));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void completeThreadWithBranchesCanBeRequestedByReplyToIntermediateComment() {
+    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
+    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
+    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+    HumanComment sibling1Child =
+        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+    HumanComment sibling2Child =
+        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+
+    HumanComment reply = asReply(createComment("sibling1"), "root");
+
+    ImmutableList<HumanComment> comments =
+        ImmutableList.of(root, sibling1, sibling2, sibling1Child, sibling2Child);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(reply));
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+        ImmutableSet.of(toThread(root, sibling1, sibling2, sibling1Child, sibling2Child));
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  @Test
+  public void requestedThreadsAreEmptyIfReplyDoesNotReferToAThread() {
+    HumanComment root = createComment("root");
+
+    HumanComment reply = asReply(createComment("reply"), "invalid");
+
+    ImmutableList<HumanComment> comments = ImmutableList.of(root);
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(reply));
+
+    ImmutableSet<CommentThread<HumanComment>> expectedThreads = ImmutableSet.of();
+    assertThat(commentThreads).isEqualTo(expectedThreads);
+  }
+
+  private static HumanComment createComment(String commentUuid) {
+    return new HumanComment(
+        new Key(commentUuid, "myFile", 1),
+        Account.id(100),
+        new Timestamp(1234),
+        (short) 1,
+        "Comment text",
+        "serverId",
+        true);
+  }
+
+  private static HumanComment asReply(HumanComment comment, String parentUuid) {
+    comment.parentUuid = parentUuid;
+    return comment;
+  }
+
+  private static HumanComment writtenOn(HumanComment comment, Timestamp writtenOn) {
+    comment.writtenOn = writtenOn;
+    return comment;
+  }
+
+  private static CommentThread<HumanComment> toThread(HumanComment... comments) {
+    return CommentThread.<HumanComment>builder().comments(ImmutableList.copyOf(comments)).build();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 39914b9..e5f3e23 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.change;
 
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
-import static com.google.gerrit.common.data.Permission.forLabel;
+import static com.google.gerrit.entities.Permission.forLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
@@ -23,11 +23,11 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -98,14 +98,19 @@
 
   private void configureProject() throws Exception {
     ProjectConfig pc = loadAllProjects();
-    for (AccessSection sec : pc.getAccessSections()) {
-      for (String label : pc.getLabelSections().keySet()) {
-        sec.removePermission(forLabel(label));
-      }
+
+    for (AccessSection sec : ImmutableList.copyOf(pc.getAccessSections())) {
+      pc.upsertAccessSection(
+          sec.getName(),
+          updatedSection -> {
+            for (String label : pc.getLabelSections().keySet()) {
+              updatedSection.removePermission(forLabel(label));
+            }
+          });
     }
     LabelType lt =
         label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
-    pc.getLabelSections().put(lt.getName(), lt);
+    pc.upsertLabelType(lt);
     save(pc);
   }
 
diff --git a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
index cb6de34..7316074 100644
--- a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
@@ -24,7 +24,8 @@
 
   @Test
   public void validPathSeparator() {
-    for (char c : VALID_CHARACTERS.toCharArray()) {
+    for (int i = 0; i < VALID_CHARACTERS.length(); i++) {
+      char c = VALID_CHARACTERS.charAt(i);
       assertWithMessage("valid character rejected: " + c)
           .that(GitwebConfig.isValidPathSeparator(c))
           .isTrue();
@@ -33,7 +34,8 @@
 
   @Test
   public void inalidPathSeparator() {
-    for (char c : SOME_INVALID_CHARACTERS.toCharArray()) {
+    for (int i = 0; i < SOME_INVALID_CHARACTERS.length(); i++) {
+      char c = SOME_INVALID_CHARACTERS.charAt(i);
       assertWithMessage("invalid character accepted: " + c)
           .that(GitwebConfig.isValidPathSeparator(c))
           .isFalse();
diff --git a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
index a618c9e..6309944 100644
--- a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
+++ b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
@@ -18,6 +18,7 @@
 
 import com.google.common.io.CharStreams;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.restapi.RawInput;
@@ -45,9 +46,9 @@
     this.modification = modification;
   }
 
-  public StringSubject filePath() {
+  public IterableSubject filePaths() {
     isNotNull();
-    return check("getFilePath()").that(modification.getFilePath());
+    return check("getFilePaths()").that(modification.getFilePaths());
   }
 
   public StringSubject newContent() throws IOException {
diff --git a/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java b/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java
new file mode 100644
index 0000000..7e1a23b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.edit.tree;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.RawInputUtil;
+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.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TreeCreatorTest {
+
+  private Repository repository;
+  private TestRepository<?> testRepository;
+
+  @Before
+  public void setUp() throws Exception {
+    repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
+    testRepository = new TestRepository<>(repository);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (testRepository != null) {
+      testRepository.close();
+    }
+  }
+
+  @Test
+  public void fileContentModificationWorksWithEmptyTree() throws Exception {
+    TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+    treeCreator.addTreeModifications(
+        ImmutableList.of(
+            new ChangeFileContentModification("file.txt", RawInputUtil.create("Line 1"))));
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    String fileContent = getFileContent(newTreeId, "file.txt");
+    assertThat(fileContent).isEqualTo("Line 1");
+  }
+
+  @Test
+  public void renameFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+    TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+    treeCreator.addTreeModifications(
+        ImmutableList.of(new RenameFileModification("oldfileName", "newFileName")));
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    assertThat(isEmptyTree(newTreeId)).isTrue();
+  }
+
+  @Test
+  public void deleteFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+    TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+    treeCreator.addTreeModifications(ImmutableList.of(new DeleteFileModification("file.txt")));
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    assertThat(isEmptyTree(newTreeId)).isTrue();
+  }
+
+  @Test
+  public void restoreFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+    TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+    treeCreator.addTreeModifications(ImmutableList.of(new RestoreFileModification("file.txt")));
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    assertThat(isEmptyTree(newTreeId)).isTrue();
+  }
+
+  @Test
+  public void modificationsMustNotReferToSameFilePaths() {
+    TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+    treeCreator.addTreeModifications(
+        ImmutableList.of(
+            new RenameFileModification("oldFileName", "newFileName"),
+            new ChangeFileContentModification(
+                "newFileName", RawInputUtil.create("Different content"))));
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class, () -> treeCreator.createNewTreeAndGetId(repository));
+
+    assertThat(exception).hasMessageThat().contains("oldFileName");
+    assertThat(exception).hasMessageThat().contains("newFileName");
+  }
+
+  @Test
+  public void fileContentModificationRefersToModifiedFile() {
+    ChangeFileContentModification contentModification =
+        new ChangeFileContentModification("myFileName", RawInputUtil.create("Some content"));
+    assertThat(contentModification.getFilePaths()).containsExactly("myFileName");
+  }
+
+  @Test
+  public void renameFileModificationRefersToOldAndNewFilePath() {
+    RenameFileModification fileModification =
+        new RenameFileModification("oldFileName", "newFileName");
+    assertThat(fileModification.getFilePaths()).containsExactly("oldFileName", "newFileName");
+  }
+
+  @Test
+  public void deleteFileModificationRefersToDeletedFile() {
+    DeleteFileModification fileModification = new DeleteFileModification("myFileName");
+    assertThat(fileModification.getFilePaths()).containsExactly("myFileName");
+  }
+
+  @Test
+  public void restoreFileModificationRefersToRestoredFile() {
+    RestoreFileModification fileModification = new RestoreFileModification("myFileName");
+    assertThat(fileModification.getFilePaths()).containsExactly("myFileName");
+  }
+
+  private String getFileContent(ObjectId treeId, String filePath) throws Exception {
+    try (RevWalk revWalk = new RevWalk(repository);
+        ObjectReader reader = revWalk.getObjectReader()) {
+      RevTree revTree = revWalk.parseTree(treeId);
+      RevObject revObject = testRepository.get(revTree, filePath);
+      return new String(reader.open(revObject, OBJ_BLOB).getBytes(), UTF_8);
+    }
+  }
+
+  private boolean isEmptyTree(ObjectId treeId) throws Exception {
+    try (TreeWalk treeWalk = new TreeWalk(repository)) {
+      treeWalk.reset(treeId);
+      return !treeWalk.next();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/events/BUILD b/javatests/com/google/gerrit/server/events/BUILD
index eed83c8..be983a9 100644
--- a/javatests/com/google/gerrit/server/events/BUILD
+++ b/javatests/com/google/gerrit/server/events/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/data",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:gson",
diff --git a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
index 8b5705b..a3e86a3 100644
--- a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
+++ b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
@@ -15,7 +15,8 @@
 package com.google.gerrit.server.fixes;
 
 import static com.google.gerrit.server.edit.tree.TreeModificationSubject.assertThatList;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+import static java.util.Comparator.comparing;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -23,12 +24,11 @@
 import com.google.gerrit.entities.Comment.Range;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.CommitModification;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.ArrayList;
-import java.util.Comparator;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -52,8 +52,29 @@
 
   @Test
   public void noReplacementsResultInNoTreeModifications() throws Exception {
-    List<TreeModification> treeModifications = toTreeModifications();
-    assertThatList(treeModifications).isEmpty();
+    CommitModification commitModification = toCommitModification();
+    assertThatList(commitModification.treeModifications()).isEmpty();
+    assertThat(commitModification.newCommitMessage()).isEmpty();
+  }
+
+  @Test
+  public void replacementIsTranslatedToTreeModification() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, 1, 3, 2), "Modified content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    CommitModification commitModification = toCommitModification(fixReplacement);
+    ImmutableList<TreeModification> treeModifications = commitModification.treeModifications();
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .filePaths()
+        .containsExactly(filePath1);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("FModified contentird line\n");
   }
 
   @Test
@@ -67,14 +88,15 @@
         new FixReplacement(filePath2, new Range(2, 0, 3, 0), "Another modified content");
     mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
 
-    List<TreeModification> treeModifications =
-        toTreeModifications(fixReplacement, fixReplacement3, fixReplacement2);
-    List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+    CommitModification commitModification =
+        toCommitModification(fixReplacement, fixReplacement3, fixReplacement2);
+    List<TreeModification> sortedTreeModifications =
+        getSortedCopy(commitModification.treeModifications());
     assertThatList(sortedTreeModifications)
         .element(0)
         .asChangeFileContentModification()
-        .filePath()
-        .isEqualTo(filePath1);
+        .filePaths()
+        .containsExactly(filePath1);
     assertThatList(sortedTreeModifications)
         .element(0)
         .asChangeFileContentModification()
@@ -83,8 +105,8 @@
     assertThatList(sortedTreeModifications)
         .element(1)
         .asChangeFileContentModification()
-        .filePath()
-        .isEqualTo(filePath2);
+        .filePaths()
+        .containsExactly(filePath2);
     assertThatList(sortedTreeModifications)
         .element(1)
         .asChangeFileContentModification()
@@ -93,137 +115,6 @@
   }
 
   @Test
-  public void replacementsCanDeleteALine() throws Exception {
-    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 0, 3, 0), "");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo("First line\nThird line\n");
-  }
-
-  @Test
-  public void replacementsCanAddALine() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(2, 0, 2, 0), "A new line\n");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo("First line\nA new line\nSecond line\nThird line\n");
-  }
-
-  @Test
-  public void replacementsMaySpanMultipleLines() throws Exception {
-    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(1, 6, 3, 1), "and t");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo("First and third line\n");
-  }
-
-  @Test
-  public void replacementsMayOccurOnSameLine() throws Exception {
-    FixReplacement fixReplacement1 = new FixReplacement(filePath1, new Range(2, 0, 2, 6), "A");
-    FixReplacement fixReplacement2 =
-        new FixReplacement(filePath1, new Range(2, 7, 2, 11), "modification");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    List<TreeModification> treeModifications =
-        toTreeModifications(fixReplacement1, fixReplacement2);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo("First line\nA modification\nThird line\n");
-  }
-
-  @Test()
-  public void startAfterEndOfLineMarkThrowsAnException() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(1, 11, 2, 6), "A modification");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
-  }
-
-  @Test()
-  public void endAfterEndOfLineMarkThrowsAnException() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(2, 0, 2, 12), "A modification");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
-  }
-
-  @Test
-  public void replacementsMayTouch() throws Exception {
-    FixReplacement fixReplacement1 =
-        new FixReplacement(filePath1, new Range(1, 6, 2, 7), "modified ");
-    FixReplacement fixReplacement2 =
-        new FixReplacement(filePath1, new Range(2, 7, 3, 5), "content");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    List<TreeModification> treeModifications =
-        toTreeModifications(fixReplacement1, fixReplacement2);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo("First modified content line\n");
-  }
-
-  @Test
-  public void replacementsCanAddContentAtEndOfFile() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(4, 0, 4, 0), "New content");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo("First line\nSecond line\nThird line\nNew content");
-  }
-
-  @Test
-  public void replacementsCanChangeLastLine() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(3, 0, 4, 0), "New content\n");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo("First line\nSecond line\nNew content\n");
-  }
-
-  @Test
-  public void replacementsCanChangeLastLineWithoutEOLMark() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(3, 0, 3, 10), "New content\n");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line");
-
-    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo("First line\nSecond line\nNew content\n");
-  }
-
-  @Test
   public void replacementsCanModifySeveralFilesInAnyOrder() throws Exception {
     FixReplacement fixReplacement1 =
         new FixReplacement(filePath1, new Range(1, 1, 3, 2), "Modified content");
@@ -234,9 +125,10 @@
         new FixReplacement(filePath2, new Range(3, 0, 4, 0), "Second modification\n");
     mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
 
-    List<TreeModification> treeModifications =
-        toTreeModifications(fixReplacement3, fixReplacement1, fixReplacement2);
-    List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+    CommitModification commitModification =
+        toCommitModification(fixReplacement3, fixReplacement1, fixReplacement2);
+    List<TreeModification> sortedTreeModifications =
+        getSortedCopy(commitModification.treeModifications());
     assertThatList(sortedTreeModifications)
         .element(0)
         .asChangeFileContentModification()
@@ -249,98 +141,23 @@
         .isEqualTo("1st line\nFirst modification\nSecond modification\n");
   }
 
-  @Test
-  public void lineSeparatorCanBeChanged() throws Exception {
-    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 11, 3, 0), "\r");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo("First line\nSecond line\rThird line\n");
-  }
-
-  @Test
-  public void replacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
-    FixReplacement fixReplacement1 =
-        new FixReplacement(filePath1, new Range(1, 0, 2, 0), "1st modification\n");
-    FixReplacement fixReplacement2 =
-        new FixReplacement(filePath1, new Range(3, 0, 4, 0), "2nd modification\n");
-    FixReplacement fixReplacement3 =
-        new FixReplacement(filePath1, new Range(4, 0, 5, 0), "3rd modification\n");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\nFourth line\nFifth line\n");
-
-    List<TreeModification> treeModifications =
-        toTreeModifications(fixReplacement2, fixReplacement1, fixReplacement3);
-    assertThatList(treeModifications)
-        .onlyElement()
-        .asChangeFileContentModification()
-        .newContent()
-        .isEqualTo(
-            "1st modification\nSecond line\n2nd modification\n3rd modification\nFifth line\n");
-  }
-
-  @Test
-  public void replacementsMustNotReferToNotExistingLine() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(5, 0, 5, 0), "A new line\n");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
-  }
-
-  @Test
-  public void replacementsMustNotReferToZeroLine() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(0, 0, 0, 0), "A new line\n");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
-  }
-
-  @Test
-  public void replacementsMustNotReferToNotExistingOffsetOfIntermediateLine() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(1, 0, 1, 11), "modified");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
-  }
-
-  @Test
-  public void replacementsMustNotReferToNotExistingOffsetOfLastLine() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(3, 0, 3, 11), "modified");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
-  }
-
-  @Test
-  public void replacementsMustNotReferToNegativeOffset() throws Exception {
-    FixReplacement fixReplacement =
-        new FixReplacement(filePath1, new Range(1, -1, 1, 5), "modified");
-    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
-    assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
-  }
-
   private void mockFileContent(String filePath, String fileContent) throws Exception {
     when(fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath))
         .thenReturn(BinaryResult.create(fileContent));
   }
 
-  private List<TreeModification> toTreeModifications(FixReplacement... fixReplacements)
+  private CommitModification toCommitModification(FixReplacement... fixReplacements)
       throws Exception {
-    return fixReplacementInterpreter.toTreeModifications(
+    return fixReplacementInterpreter.toCommitModification(
         repository, projectState, patchSetCommitId, ImmutableList.copyOf(fixReplacements));
   }
 
   private static List<TreeModification> getSortedCopy(List<TreeModification> treeModifications) {
     List<TreeModification> sortedTreeModifications = new ArrayList<>(treeModifications);
-    sortedTreeModifications.sort(Comparator.comparing(TreeModification::getFilePath));
+    // The sorting is only necessary to get a deterministic order. The exact order doesn't matter.
+    sortedTreeModifications.sort(
+        comparing(
+            treeModification -> treeModification.getFilePaths().stream().findFirst().orElse("")));
     return sortedTreeModifications;
   }
 }
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
index 861af3e..fa5c47f 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -34,7 +34,7 @@
       "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(
+  static FixResult calculateFixSingleReplacement(
       String content, int startLine, int startChar, int endLine, int endChar, String replacement)
       throws ResourceConflictException {
     FixReplacement fixReplacement =
@@ -52,13 +52,66 @@
   }
 
   @Test
-  public void insertAtTheEndOfSingleLineContentHasEOLMarkInvalidPosition() throws Exception {
+  public void lineNumberMustExist() {
+    assertThrows(
+        ResourceConflictException.class,
+        () -> calculateFixSingleReplacement("First line\nSecond line", 4, 0, 4, 0, "Abc"));
+  }
+
+  @Test
+  public void startOffsetMustNotBeNegative() {
+    assertThrows(
+        ResourceConflictException.class,
+        () -> calculateFixSingleReplacement("First line\nSecond line", 0, -1, 0, 0, "Abc"));
+  }
+
+  @Test
+  public void endOffsetMustNotBeNegative() {
+    assertThrows(
+        ResourceConflictException.class,
+        () -> calculateFixSingleReplacement("First line\nSecond line", 0, 0, 0, -1, "Abc"));
+  }
+
+  @Test
+  public void insertAtTheEndOfSingleLineContentHasEOLMarkInvalidPosition() {
     assertThrows(
         ResourceConflictException.class,
         () -> calculateFixSingleReplacement("First line\n", 1, 11, 1, 11, "Abc"));
   }
 
   @Test
+  public void startAfterEndOfLineMarkOfIntermediateLineThrowsAnException() {
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            calculateFixSingleReplacement(
+                "First line\nSecond line\nThird line\n", 1, 11, 2, 6, "Abc"));
+  }
+
+  @Test
+  public void startAfterEndOfLineMarkOfLastLineThrowsAnException() {
+    assertThrows(
+        ResourceConflictException.class,
+        () -> calculateFixSingleReplacement("First line\n", 1, 11, 2, 0, "Abc"));
+  }
+
+  @Test
+  public void endAfterEndOfLineMarkOfIntermediateLineThrowsAnException() {
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            calculateFixSingleReplacement(
+                "First line\nSecond line\nThird line\n", 2, 0, 2, 12, "Abc"));
+  }
+
+  @Test
+  public void endAfterEndOfLineMarkOfLastLineThrowsAnException() {
+    assertThrows(
+        ResourceConflictException.class,
+        () -> calculateFixSingleReplacement("First line\nSecond line\n", 2, 0, 2, 12, "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");
@@ -146,7 +199,18 @@
     assertThat(result)
         .text()
         .isEqualTo(
-            "FiAB\nC\nDEFG\nQ\nrd line\nFourth lneQWERTY\nSixth line\nSevXY line\nEighth KLMNO\nASDFline\nNinth line\nTenine\n");
+            "FiAB\n"
+                + "C\n"
+                + "DEFG\n"
+                + "Q\n"
+                + "rd line\n"
+                + "Fourth lneQWERTY\n"
+                + "Sixth line\n"
+                + "SevXY line\n"
+                + "Eighth KLMNO\n"
+                + "ASDFline\n"
+                + "Ninth line\n"
+                + "Tenine\n");
     assertThat(result).edits().hasSize(3);
     assertThat(result).edits().element(0).isReplace(0, 5, 0, 6);
     assertThat(result)
@@ -165,4 +229,23 @@
     assertThat(result).edits().element(2).isReplace(9, 1, 11, 1);
     assertThat(result).edits().element(2).internalEdits().onlyElement().isDelete(3, 4, 3);
   }
+
+  @Test
+  public void changesMayTouch() throws Exception {
+    FixReplacement firstReplace = new FixReplacement("path", new Range(1, 6, 2, 7), "modified ");
+    FixReplacement consecutiveReplace =
+        new FixReplacement("path", new Range(2, 7, 3, 5), "content");
+    FixResult result =
+        FixCalculator.calculateFix(
+            multilineContent, ImmutableList.of(firstReplace, consecutiveReplace));
+    assertThat(result).text().isEqualTo("First modified content line\nFourth line\nFifth line\n");
+    assertThat(result).edits().hasSize(1);
+    Edit edit = result.edits.get(0);
+    assertThat(edit).isReplace(0, 3, 0, 1);
+    // The current code creates two inline edits even though only one would be necessary. It
+    // shouldn't make a visual difference to the user and hence we can ignore this.
+    assertThat(edit).internalEdits().hasSize(2);
+    assertThat(edit).internalEdits().element(0).isReplace(6, 12, 6, 9);
+    assertThat(edit).internalEdits().element(1).isReplace(18, 10, 15, 7);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index c749b77..20fe387 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -17,9 +17,9 @@
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.config.AllUsersName;
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index b7fe23d..c1f3615 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -25,9 +25,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index df97e88..3b7beb9 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -25,9 +25,9 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.testing.GroupReferenceSubject;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
@@ -393,8 +393,8 @@
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
 
-    GroupReference group1 = new GroupReference(groupUuid1, groupName1.get());
-    GroupReference group2 = new GroupReference(groupUuid2, groupName2.get());
+    GroupReference group1 = GroupReference.create(groupUuid1, groupName1.get());
+    GroupReference group2 = GroupReference.create(groupUuid2, groupName2.get());
     assertThat(allGroups).containsExactly(group1, group2);
   }
 
@@ -406,8 +406,8 @@
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
 
-    GroupReference group1 = new GroupReference(groupUuid, groupName.get());
-    GroupReference group2 = new GroupReference(groupUuid, anotherGroupName.get());
+    GroupReference group1 = GroupReference.create(groupUuid, groupName.get());
+    GroupReference group2 = GroupReference.create(groupUuid, anotherGroupName.get());
     assertThat(allGroups).containsExactly(group1, group2);
   }
 
@@ -498,14 +498,14 @@
   @Test
   public void updateGroupNamesRejectsNonOneToOneGroupReferences() throws Exception {
     assertIllegalArgument(
-        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
-        new GroupReference(AccountGroup.uuid("uuid1"), "name2"));
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name1"),
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name2"));
     assertIllegalArgument(
-        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
-        new GroupReference(AccountGroup.uuid("uuid2"), "name1"));
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name1"),
+        GroupReference.create(AccountGroup.uuid("uuid2"), "name1"));
     assertIllegalArgument(
-        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
-        new GroupReference(AccountGroup.uuid("uuid1"), "name1"));
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name1"),
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name1"));
   }
 
   @Test
@@ -554,7 +554,7 @@
 
   private GroupReference newGroup(String name) {
     int id = idCounter.incrementAndGet();
-    return new GroupReference(AccountGroup.uuid(name + "-" + id), name);
+    return GroupReference.create(AccountGroup.uuid(name + "-" + id), name);
   }
 
   private static PersonIdent newPersonIdent() {
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 47877b6..40a6978 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -22,10 +22,10 @@
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Table;
-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.Change;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.util.time.TimeUtil;
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index a936d28..521af2f 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -44,6 +44,7 @@
             null,
             null,
             null,
+            null,
             indexes,
             null,
             null,
diff --git a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
index 805c542..d8e29f9 100644
--- a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
+++ b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.Instant;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java b/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
deleted file mode 100644
index f4fbc78..0000000
--- a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
+++ /dev/null
@@ -1,450 +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.mail.send;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.LIST;
-import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PARAGRAPH;
-import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
-import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
-
-import java.util.List;
-import org.junit.Test;
-
-public class CommentFormatterTest {
-  private void assertBlock(
-      List<CommentFormatter.Block> list, int index, CommentFormatter.BlockType type, String text) {
-    CommentFormatter.Block block = list.get(index);
-    assertThat(block.type).isEqualTo(type);
-    assertThat(block.text).isEqualTo(text);
-    assertThat(block.items).isNull();
-    assertThat(block.quotedBlocks).isNull();
-  }
-
-  private void assertListBlock(
-      List<CommentFormatter.Block> list, int index, int itemIndex, String text) {
-    CommentFormatter.Block block = list.get(index);
-    assertThat(block.type).isEqualTo(LIST);
-    assertThat(block.items.get(itemIndex)).isEqualTo(text);
-    assertThat(block.text).isNull();
-    assertThat(block.quotedBlocks).isNull();
-  }
-
-  private void assertQuoteBlock(List<CommentFormatter.Block> list, int index, int size) {
-    CommentFormatter.Block block = list.get(index);
-    assertThat(block.type).isEqualTo(QUOTE);
-    assertThat(block.items).isNull();
-    assertThat(block.text).isNull();
-    assertThat(block.quotedBlocks).hasSize(size);
-  }
-
-  @Test
-  public void parseNullAsEmpty() {
-    assertThat(CommentFormatter.parse(null)).isEmpty();
-  }
-
-  @Test
-  public void parseEmpty() {
-    assertThat(CommentFormatter.parse("")).isEmpty();
-  }
-
-  @Test
-  public void parseSimple() {
-    String comment = "Para1";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PARAGRAPH, comment);
-  }
-
-  @Test
-  public void parseMultilinePara() {
-    String comment = "Para 1\nStill para 1";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PARAGRAPH, comment);
-  }
-
-  @Test
-  public void parseParaBreak() {
-    String comment = "Para 1\n\nPara 2\n\nPara 3";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "Para 1");
-    assertBlock(result, 1, PARAGRAPH, "Para 2");
-    assertBlock(result, 2, PARAGRAPH, "Para 3");
-  }
-
-  @Test
-  public void parseQuote() {
-    String comment = "> Quote text";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertQuoteBlock(result, 0, 1);
-    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
-  }
-
-  @Test
-  public void parseExcludesEmpty() {
-    String comment = "Para 1\n\n\n\nPara 2";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "Para 1");
-    assertBlock(result, 1, PARAGRAPH, "Para 2");
-  }
-
-  @Test
-  public void parseQuoteLeadSpace() {
-    String comment = " > Quote text";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertQuoteBlock(result, 0, 1);
-    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
-  }
-
-  @Test
-  public void parseMultiLineQuote() {
-    String comment = "> Quote line 1\n> Quote line 2\n > Quote line 3\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertQuoteBlock(result, 0, 1);
-    assertBlock(
-        result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote line 1\nQuote line 2\nQuote line 3\n");
-  }
-
-  @Test
-  public void parsePre() {
-    String comment = "    Four space indent.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PRE_FORMATTED, comment);
-  }
-
-  @Test
-  public void parseOneSpacePre() {
-    String comment = " One space indent.\n Another line.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PRE_FORMATTED, comment);
-  }
-
-  @Test
-  public void parseTabPre() {
-    String comment = "\tOne tab indent.\n\tAnother line.\n  Yet another!";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PRE_FORMATTED, comment);
-  }
-
-  @Test
-  public void parseIntermediateLeadingWhitespacePre() {
-    String comment = "No indent.\n\tNonzero indent.\nNo indent again.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PRE_FORMATTED, comment);
-  }
-
-  @Test
-  public void parseStarList() {
-    String comment = "* Item 1\n* Item 2\n* Item 3";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertListBlock(result, 0, 0, "Item 1");
-    assertListBlock(result, 0, 1, "Item 2");
-    assertListBlock(result, 0, 2, "Item 3");
-  }
-
-  @Test
-  public void parseDashList() {
-    String comment = "- Item 1\n- Item 2\n- Item 3";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertListBlock(result, 0, 0, "Item 1");
-    assertListBlock(result, 0, 1, "Item 2");
-    assertListBlock(result, 0, 2, "Item 3");
-  }
-
-  @Test
-  public void parseMixedList() {
-    String comment = "- Item 1\n* Item 2\n- Item 3\n* Item 4";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(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
-  public void parseMixedBlockTypes() {
-    String 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.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(7);
-    assertBlock(result, 0, PARAGRAPH, "Paragraph\nacross\na\nfew\nlines.");
-    assertQuoteBlock(result, 1, 1);
-    assertBlock(result.get(1).quotedBlocks, 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_FORMATTED, "\tPreformatted text.");
-    assertBlock(result, 6, PARAGRAPH, "Parting words.");
-  }
-
-  @Test
-  public void bulletList1() {
-    String comment = "A\n\n* line 1\n* 2nd line";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertListBlock(result, 1, 0, "line 1");
-    assertListBlock(result, 1, 1, "2nd line");
-  }
-
-  @Test
-  public void bulletList2() {
-    String comment = "A\n\n* line 1\n* 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertListBlock(result, 1, 0, "line 1");
-    assertListBlock(result, 1, 1, "2nd line");
-    assertBlock(result, 2, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void bulletList3() {
-    String comment = "* line 1\n* 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertListBlock(result, 0, 0, "line 1");
-    assertListBlock(result, 0, 1, "2nd line");
-    assertBlock(result, 1, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void bulletList4() {
-    String comment =
-        "To see this bug, you have to:\n" //
-            + "* Be on IMAP or EAS (not on POP)\n" //
-            + "* Be very unlucky\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(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
-  public void bulletList5() {
-    String comment =
-        "To see this bug,\n" //
-            + "you have to:\n" //
-            + "* Be on IMAP or EAS (not on POP)\n" //
-            + "* Be very unlucky\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(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
-  public void dashList1() {
-    String comment = "A\n\n- line 1\n- 2nd line";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertListBlock(result, 1, 0, "line 1");
-    assertListBlock(result, 1, 1, "2nd line");
-  }
-
-  @Test
-  public void dashList2() {
-    String comment = "A\n\n- line 1\n- 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertListBlock(result, 1, 0, "line 1");
-    assertListBlock(result, 1, 1, "2nd line");
-    assertBlock(result, 2, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void dashList3() {
-    String comment = "- line 1\n- 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertListBlock(result, 0, 0, "line 1");
-    assertListBlock(result, 0, 1, "2nd line");
-    assertBlock(result, 1, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void preformat1() {
-    String comment = "A\n\n  This is pre\n  formatted";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
-  }
-
-  @Test
-  public void preformat2() {
-    String comment = "A\n\n  This is pre\n  formatted\n\nbut this is not";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
-    assertBlock(result, 2, PARAGRAPH, "but this is not");
-  }
-
-  @Test
-  public void preformat3() {
-    String comment = "A\n\n  Q\n    <R>\n  S\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertBlock(result, 1, PRE_FORMATTED, "  Q\n    <R>\n  S");
-    assertBlock(result, 2, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void preformat4() {
-    String comment = "  Q\n    <R>\n  S\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PRE_FORMATTED, "  Q\n    <R>\n  S");
-    assertBlock(result, 1, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void quote1() {
-    String comment = "> I'm happy\n > with quotes!\n\nSee above.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertQuoteBlock(result, 0, 1);
-    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "I'm happy\nwith quotes!");
-    assertBlock(result, 1, PARAGRAPH, "See above.");
-  }
-
-  @Test
-  public void quote2() {
-    String comment = "See this said:\n\n > a quoted\n > string block\n\nOK?";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "See this said:");
-    assertQuoteBlock(result, 1, 1);
-    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH, "a quoted\nstring block");
-    assertBlock(result, 2, PARAGRAPH, "OK?");
-  }
-
-  @Test
-  public void nestedQuotes1() {
-    String comment = " > > prior\n > \n > next\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertQuoteBlock(result, 0, 2);
-    assertQuoteBlock(result.get(0).quotedBlocks, 0, 1);
-    assertBlock(result.get(0).quotedBlocks.get(0).quotedBlocks, 0, PARAGRAPH, "prior");
-    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "next\n");
-  }
-
-  @Test
-  public void largeMixedQuote() {
-    String comment =
-        "> > Paragraph 1.\n"
-            + "> > \n"
-            + "> > > Paragraph 2.\n"
-            + "> > \n"
-            + "> > Paragraph 3.\n"
-            + "> > \n"
-            + "> >    pre line 1;\n"
-            + "> >    pre line 2;\n"
-            + "> > \n"
-            + "> > Paragraph 4.\n"
-            + "> > \n"
-            + "> > * List item 1.\n"
-            + "> > * List item 2.\n"
-            + "> > \n"
-            + "> > Paragraph 5.\n"
-            + "> \n"
-            + "> Paragraph 6.\n"
-            + "\n"
-            + "Paragraph 7.\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertQuoteBlock(result, 0, 2);
-
-    assertQuoteBlock(result.get(0).quotedBlocks, 0, 7);
-    List<CommentFormatter.Block> bigQuote = result.get(0).quotedBlocks.get(0).quotedBlocks;
-    assertBlock(bigQuote, 0, PARAGRAPH, "Paragraph 1.");
-    assertQuoteBlock(bigQuote, 1, 1);
-    assertBlock(bigQuote.get(1).quotedBlocks, 0, PARAGRAPH, "Paragraph 2.");
-    assertBlock(bigQuote, 2, PARAGRAPH, "Paragraph 3.");
-    assertBlock(bigQuote, 3, PRE_FORMATTED, "   pre line 1;\n   pre line 2;");
-    assertBlock(bigQuote, 4, PARAGRAPH, "Paragraph 4.");
-    assertListBlock(bigQuote, 5, 0, "List item 1.");
-    assertListBlock(bigQuote, 5, 1, "List item 2.");
-    assertBlock(bigQuote, 6, PARAGRAPH, "Paragraph 5.");
-    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "Paragraph 6.");
-    assertBlock(result, 1, PARAGRAPH, "Paragraph 7.\n");
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index a383d56..f10a281 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -22,7 +22,7 @@
 import static org.mockito.Mockito.when;
 
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -146,9 +146,9 @@
   @Test
   public void USERNoAllowDomain() {
     setFrom("USER");
-    setDomains(Arrays.asList("example.com"));
+    setDomains(Arrays.asList("example.net"));
     final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
+    final String email = "a.u.thor@example.com";
     final Account.Id user = user(name, email);
 
     final Address r = create().from(user);
@@ -161,10 +161,10 @@
   @Test
   public void USERAllowDomainTwice() {
     setFrom("USER");
+    setDomains(Arrays.asList("example.net"));
     setDomains(Arrays.asList("example.com"));
-    setDomains(Arrays.asList("test.com"));
     final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
+    final String email = "a.u.thor@example.com";
     final Account.Id user = user(name, email);
 
     final Address r = create().from(user);
@@ -177,10 +177,10 @@
   @Test
   public void USERAllowDomainTwiceReverse() {
     setFrom("USER");
-    setDomains(Arrays.asList("test.com"));
     setDomains(Arrays.asList("example.com"));
+    setDomains(Arrays.asList("example.net"));
     final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
+    final String email = "a.u.thor@example.com";
     final Account.Id user = user(name, email);
 
     final Address r = create().from(user);
@@ -193,9 +193,9 @@
   @Test
   public void USERAllowTwoDomains() {
     setFrom("USER");
-    setDomains(Arrays.asList("example.com", "test.com"));
+    setDomains(Arrays.asList("example.com", "example.net"));
     final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
+    final String email = "a.u.thor@example.com";
     final Account.Id user = user(name, email);
 
     final Address r = create().from(user);
diff --git a/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java b/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
new file mode 100644
index 0000000..46ea8b2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
@@ -0,0 +1,450 @@
+// 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.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.LIST;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PARAGRAPH;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
+
+import java.util.List;
+import org.junit.Test;
+
+public class HumanCommentFormatterTest {
+  private void assertBlock(
+      List<CommentFormatter.Block> list, int index, CommentFormatter.BlockType type, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(type);
+    assertThat(block.text).isEqualTo(text);
+    assertThat(block.items).isNull();
+    assertThat(block.quotedBlocks).isNull();
+  }
+
+  private void assertListBlock(
+      List<CommentFormatter.Block> list, int index, int itemIndex, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(LIST);
+    assertThat(block.items.get(itemIndex)).isEqualTo(text);
+    assertThat(block.text).isNull();
+    assertThat(block.quotedBlocks).isNull();
+  }
+
+  private void assertQuoteBlock(List<CommentFormatter.Block> list, int index, int size) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(QUOTE);
+    assertThat(block.items).isNull();
+    assertThat(block.text).isNull();
+    assertThat(block.quotedBlocks).hasSize(size);
+  }
+
+  @Test
+  public void parseNullAsEmpty() {
+    assertThat(CommentFormatter.parse(null)).isEmpty();
+  }
+
+  @Test
+  public void parseEmpty() {
+    assertThat(CommentFormatter.parse("")).isEmpty();
+  }
+
+  @Test
+  public void parseSimple() {
+    String comment = "Para1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void parseMultilinePara() {
+    String comment = "Para 1\nStill para 1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void parseParaBreak() {
+    String comment = "Para 1\n\nPara 2\n\nPara 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+    assertBlock(result, 2, PARAGRAPH, "Para 3");
+  }
+
+  @Test
+  public void parseQuote() {
+    String comment = "> Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
+  }
+
+  @Test
+  public void parseExcludesEmpty() {
+    String comment = "Para 1\n\n\n\nPara 2";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+  }
+
+  @Test
+  public void parseQuoteLeadSpace() {
+    String comment = " > Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
+  }
+
+  @Test
+  public void parseMultiLineQuote() {
+    String comment = "> Quote line 1\n> Quote line 2\n > Quote line 3\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(
+        result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote line 1\nQuote line 2\nQuote line 3\n");
+  }
+
+  @Test
+  public void parsePre() {
+    String comment = "    Four space indent.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseOneSpacePre() {
+    String comment = " One space indent.\n Another line.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseTabPre() {
+    String comment = "\tOne tab indent.\n\tAnother line.\n  Yet another!";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseIntermediateLeadingWhitespacePre() {
+    String comment = "No indent.\n\tNonzero indent.\nNo indent again.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseStarList() {
+    String comment = "* Item 1\n* Item 2\n* Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void parseDashList() {
+    String comment = "- Item 1\n- Item 2\n- Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void parseMixedList() {
+    String comment = "- Item 1\n* Item 2\n- Item 3\n* Item 4";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(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
+  public void parseMixedBlockTypes() {
+    String 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.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(7);
+    assertBlock(result, 0, PARAGRAPH, "Paragraph\nacross\na\nfew\nlines.");
+    assertQuoteBlock(result, 1, 1);
+    assertBlock(result.get(1).quotedBlocks, 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_FORMATTED, "\tPreformatted text.");
+    assertBlock(result, 6, PARAGRAPH, "Parting words.");
+  }
+
+  @Test
+  public void bulletList1() {
+    String comment = "A\n\n* line 1\n* 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void bulletList2() {
+    String comment = "A\n\n* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void bulletList3() {
+    String comment = "* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void bulletList4() {
+    String comment =
+        "To see this bug, you have to:\n" //
+            + "* Be on IMAP or EAS (not on POP)\n" //
+            + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(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
+  public void bulletList5() {
+    String comment =
+        "To see this bug,\n" //
+            + "you have to:\n" //
+            + "* Be on IMAP or EAS (not on POP)\n" //
+            + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(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
+  public void dashList1() {
+    String comment = "A\n\n- line 1\n- 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void dashList2() {
+    String comment = "A\n\n- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void dashList3() {
+    String comment = "- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void preformat1() {
+    String comment = "A\n\n  This is pre\n  formatted";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+  }
+
+  @Test
+  public void preformat2() {
+    String comment = "A\n\n  This is pre\n  formatted\n\nbut this is not";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+    assertBlock(result, 2, PARAGRAPH, "but this is not");
+  }
+
+  @Test
+  public void preformat3() {
+    String comment = "A\n\n  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void preformat4() {
+    String comment = "  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void quote1() {
+    String comment = "> I'm happy\n > with quotes!\n\nSee above.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "I'm happy\nwith quotes!");
+    assertBlock(result, 1, PARAGRAPH, "See above.");
+  }
+
+  @Test
+  public void quote2() {
+    String comment = "See this said:\n\n > a quoted\n > string block\n\nOK?";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "See this said:");
+    assertQuoteBlock(result, 1, 1);
+    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH, "a quoted\nstring block");
+    assertBlock(result, 2, PARAGRAPH, "OK?");
+  }
+
+  @Test
+  public void nestedQuotes1() {
+    String comment = " > > prior\n > \n > next\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 2);
+    assertQuoteBlock(result.get(0).quotedBlocks, 0, 1);
+    assertBlock(result.get(0).quotedBlocks.get(0).quotedBlocks, 0, PARAGRAPH, "prior");
+    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "next\n");
+  }
+
+  @Test
+  public void largeMixedQuote() {
+    String comment =
+        "> > Paragraph 1.\n"
+            + "> > \n"
+            + "> > > Paragraph 2.\n"
+            + "> > \n"
+            + "> > Paragraph 3.\n"
+            + "> > \n"
+            + "> >    pre line 1;\n"
+            + "> >    pre line 2;\n"
+            + "> > \n"
+            + "> > Paragraph 4.\n"
+            + "> > \n"
+            + "> > * List item 1.\n"
+            + "> > * List item 2.\n"
+            + "> > \n"
+            + "> > Paragraph 5.\n"
+            + "> \n"
+            + "> Paragraph 6.\n"
+            + "\n"
+            + "Paragraph 7.\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertQuoteBlock(result, 0, 2);
+
+    assertQuoteBlock(result.get(0).quotedBlocks, 0, 7);
+    List<CommentFormatter.Block> bigQuote = result.get(0).quotedBlocks.get(0).quotedBlocks;
+    assertBlock(bigQuote, 0, PARAGRAPH, "Paragraph 1.");
+    assertQuoteBlock(bigQuote, 1, 1);
+    assertBlock(bigQuote.get(1).quotedBlocks, 0, PARAGRAPH, "Paragraph 2.");
+    assertBlock(bigQuote, 2, PARAGRAPH, "Paragraph 3.");
+    assertBlock(bigQuote, 3, PRE_FORMATTED, "   pre line 1;\n   pre line 2;");
+    assertBlock(bigQuote, 4, PARAGRAPH, "Paragraph 4.");
+    assertListBlock(bigQuote, 5, 0, "List item 1.");
+    assertListBlock(bigQuote, 5, 1, "List item 2.");
+    assertBlock(bigQuote, 6, PARAGRAPH, "Paragraph 5.");
+    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "Paragraph 6.");
+    assertBlock(result, 1, PARAGRAPH, "Paragraph 7.\n");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 7192c55..83c6542 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -18,13 +18,14 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
@@ -37,6 +38,7 @@
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
@@ -164,6 +166,7 @@
                 bind(ExecutorService.class)
                     .annotatedWith(FanOutExecutor.class)
                     .toInstance(assertableFanOutExecutor);
+                bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
               }
             });
 
@@ -244,7 +247,7 @@
     return label;
   }
 
-  protected Comment newComment(
+  protected HumanComment newComment(
       PatchSet.Id psId,
       String filename,
       String UUID,
@@ -257,8 +260,8 @@
       short side,
       ObjectId commitId,
       boolean unresolved) {
-    Comment c =
-        new Comment(
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(UUID, filename, psId.get()),
             commenter.getAccountId(),
             t,
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
new file mode 100644
index 0000000..f105cf1
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -0,0 +1,124 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeNotesCommitTest extends AbstractChangeNotesTest {
+  private TestRepository<InMemoryRepository> testRepo;
+  private ChangeNotesCommit.ChangeNotesRevWalk walk;
+
+  @Before
+  public void setUpTestRepo() throws Exception {
+    testRepo = new TestRepository<>(repo);
+    walk = ChangeNotesCommit.newRevWalk(repo);
+  }
+
+  @After
+  public void tearDownTestRepo() throws Exception {
+    walk.close();
+  }
+
+  @Test
+  public void attentionSetCommitOnlyWhenNoChangeMessageIsPresentAndCorrectFooter()
+      throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(true);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenNoChangeMessageIsPresentAndFooterNotOnlyAS()
+      throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Subject: Change subject\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenNoChangeMessageIsPresentAndGenericFooter()
+      throws Exception {
+    RevCommit commit = writeCommit("Update patch set 1\n" + "\n" + "Patch-set: 1\n");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
+  }
+
+  @Test
+  public void noAttentionSetCommitOnlyWhenChangeMessageIsPresent() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
+
+    newParser(commit).parseAll();
+    assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(true)).isEqualTo(false);
+  }
+
+  private ChangeNotesParser newParser(ObjectId tip) throws Exception {
+    walk.reset();
+    ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
+    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+  }
+
+  private RevCommit writeCommit(String body) throws Exception {
+    Change change = newChange(true);
+    ChangeNotes notes = newNotes(change).load();
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    PersonIdent author =
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent);
+    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
+      CommitBuilder cb = new CommitBuilder();
+      cb.setParentId(notes.getRevision());
+      cb.setAuthor(author);
+      cb.setCommitter(new PersonIdent(serverIdent, author.getWhen()));
+      cb.setTreeId(testRepo.tree());
+      cb.setMessage(body);
+      ObjectId id = ins.insert(cb);
+      ins.flush();
+      RevCommit commit = walk.parseCommit(id);
+      walk.parseBody(commit);
+      return commit;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 8ffcc8b..6a32fa1 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -497,6 +497,73 @@
   }
 
   @Test
+  public void attentionSetOnlyShouldNotCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void attentionSetWithExtraFooterShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Subject: Change subject\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
+  public void changeWithoutAttentionSetShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = false;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
+  public void attentionSetWithCommentShouldCountTowardsMaxUpdatesLimit() throws Exception {
+    RevCommit commit =
+        writeCommit(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+            false);
+    ChangeNotesParser changeNotesParser = newParser(commit);
+    changeNotesParser.parseAll();
+    final boolean hasChangeMessage = true;
+    assertThat(
+            changeNotesParser.countTowardsMaxUpdatesLimit(
+                (ChangeNotesCommit) commit, hasChangeMessage))
+        .isEqualTo(true);
+  }
+
+  @Test
   public void caseInsensitiveFooters() throws Exception {
     assertParseSucceeds(
         "Update change\n"
@@ -519,7 +586,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
         false);
   }
 
@@ -531,7 +598,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(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 efbaed6..dd3238f 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -25,20 +25,21 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
-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.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -714,8 +715,8 @@
 
   @Test
   public void serializePublishedComments() throws Exception {
-    Comment c1 =
-        new Comment(
+    HumanComment c1 =
+        new HumanComment(
             new Comment.Key("uuid1", "file1", 1),
             Account.id(1001),
             new Timestamp(1212L),
@@ -726,8 +727,8 @@
     c1.setCommitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     String c1Json = Serializer.GSON.toJson(c1);
 
-    Comment c2 =
-        new Comment(
+    HumanComment c2 =
+        new HumanComment(
             new Comment.Key("uuid2", "file2", 2),
             Account.id(1002),
             new Timestamp(3434L),
@@ -798,7 +799,7 @@
                 .put("changeMessages", new TypeLiteral<ImmutableList<ChangeMessage>>() {}.getType())
                 .put(
                     "publishedComments",
-                    new TypeLiteral<ImmutableListMultimap<ObjectId, Comment>>() {}.getType())
+                    new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
                 .put("updateCount", int.class)
                 .build());
   }
@@ -970,7 +971,7 @@
                 "startChar", int.class,
                 "endLine", int.class,
                 "endChar", int.class));
-    assertThatSerializedClass(Comment.class)
+    assertThatSerializedClass(HumanComment.class)
         .hasFields(
             ImmutableMap.<String, Type>builder()
                 .put("key", Comment.Key.class)
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 964187c..6b9859f4 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -36,20 +36,20 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 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;
-import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -123,7 +123,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -142,7 +142,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, HumanComment> comments = notes.getHumanComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(tag);
   }
@@ -185,7 +185,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     update = newUpdate(c, changeOwner);
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -216,7 +216,7 @@
     assertThat(approval.tag()).hasValue(integrationTag);
     assertThat(approval.value()).isEqualTo(-1);
 
-    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, HumanComment> comments = notes.getHumanComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(coverageTag);
 
@@ -704,7 +704,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -717,12 +717,12 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
     update.commit();
     update = newUpdate(c, changeOwner);
     attentionSetUpdate =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -739,23 +739,24 @@
     IllegalArgumentException thrown =
         assertThrows(
             IllegalArgumentException.class,
-            () -> update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate)));
+            () -> update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate)));
     assertThat(thrown).hasMessageThat().contains("must not specify timestamp for write");
   }
 
   @Test
-  public void addAttentionStatus_rejectMultiplePerUser() throws Exception {
+  public void addAttentionStatus_rejectIfSameUserTwice() 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(
+                update.addToPlannedAttentionSetUpdates(
                     ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1)));
     assertThat(thrown)
         .hasMessageThat()
@@ -771,7 +772,8 @@
     AttentionSetUpdate attentionSetUpdate1 =
         AttentionSetUpdate.createForWrite(otherUser.getAccountId(), Operation.ADD, "test");
 
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1));
+    update.addToPlannedAttentionSetUpdates(
+        ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1186,7 +1188,7 @@
     update.putApproval("Code-Review", (short) 1);
     update.setChangeMessage("This is a message");
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -1206,7 +1208,7 @@
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
     assertThat(notes.getApprovals()).isNotEmpty();
     assertThat(notes.getChangeMessages()).isNotEmpty();
-    assertThat(notes.getComments()).isNotEmpty();
+    assertThat(notes.getHumanComments()).isNotEmpty();
 
     // publish ps2
     update = newUpdate(c, changeOwner);
@@ -1222,7 +1224,7 @@
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
     assertThat(notes.getApprovals()).isEmpty();
     assertThat(notes.getChangeMessages()).isEmpty();
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
   }
 
   @Test
@@ -1279,14 +1281,14 @@
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
     assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     // comment on ps2
     update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
     Timestamp ts = TimeUtil.nowTs();
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             psId2,
             "a.txt",
@@ -1307,7 +1309,7 @@
     patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
     assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
-    assertThat(notes.getComments()).isNotEmpty();
+    assertThat(notes.getHumanComments()).isNotEmpty();
   }
 
   @Test
@@ -1356,7 +1358,7 @@
     PatchSet.Id psId = c.currentPatchSetId();
     RevCommit tipCommit;
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
-      Comment comment1 =
+      HumanComment comment1 =
           newComment(
               psId,
               "file1",
@@ -1371,7 +1373,7 @@
               ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
               false);
       update1.setPatchSetId(psId);
-      update1.putComment(Comment.Status.PUBLISHED, comment1);
+      update1.putComment(HumanComment.Status.PUBLISHED, comment1);
       updateManager.add(update1);
 
       ChangeUpdate update2 = newUpdate(c, otherUser);
@@ -1570,7 +1572,7 @@
     PatchSet.Id psId = c.currentPatchSetId();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file1",
@@ -1585,11 +1587,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1600,7 +1602,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 0, 2, 0);
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file1",
@@ -1615,11 +1617,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1630,7 +1632,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(0, 0, 0, 0);
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file",
@@ -1645,11 +1647,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1660,7 +1662,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 2, 3, 4);
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "",
@@ -1675,11 +1677,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1699,7 +1701,7 @@
     Timestamp time = TimeUtil.nowTs();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId1,
             "file1",
@@ -1713,7 +1715,7 @@
             (short) 0,
             commitId,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId1,
             "file1",
@@ -1727,7 +1729,7 @@
             (short) 0,
             commitId,
             false);
-    Comment comment3 =
+    HumanComment comment3 =
         newComment(
             psId2,
             "file1",
@@ -1744,13 +1746,13 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId2);
-    update.putComment(Comment.Status.PUBLISHED, comment3);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment3);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .isEqualTo(
             ImmutableListMultimap.of(
                 commitId, comment1,
@@ -1770,7 +1772,7 @@
     PatchSet.Id psId = c.currentPatchSetId();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file",
@@ -1786,12 +1788,12 @@
             false);
     comment.setRealAuthor(changeOwner.getAccountId());
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1809,7 +1811,7 @@
     Timestamp time = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file1",
@@ -1824,12 +1826,12 @@
             ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .isEqualTo(ImmutableListMultimap.of(comment.getCommitId(), comment));
   }
 
@@ -1847,7 +1849,7 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment commentForBase =
+    HumanComment commentForBase =
         newComment(
             psId,
             "filename",
@@ -1862,11 +1864,11 @@
             commitId1,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, commentForBase);
+    update.putComment(HumanComment.Status.PUBLISHED, commentForBase);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    Comment commentForPS =
+    HumanComment commentForPS =
         newComment(
             psId,
             "filename",
@@ -1881,10 +1883,10 @@
             commitId2,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, commentForPS);
+    update.putComment(HumanComment.Status.PUBLISHED, commentForPS);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId1, commentForBase,
@@ -1905,7 +1907,7 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp timeForComment1 = TimeUtil.nowTs();
     Timestamp timeForComment2 = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId,
             filename,
@@ -1920,11 +1922,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId,
             filename,
@@ -1939,10 +1941,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId, comment1,
@@ -1963,7 +1965,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId,
             filename1,
@@ -1978,11 +1980,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId,
             filename2,
@@ -1997,10 +1999,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId, comment1,
@@ -2021,7 +2023,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2036,7 +2038,7 @@
             commitId1,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -2044,7 +2046,7 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps2,
             filename,
@@ -2059,10 +2061,10 @@
             commitId2,
             false);
     update.setPatchSetId(ps2);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId1, comment1,
@@ -2081,7 +2083,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2096,22 +2098,22 @@
             commitId,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
@@ -2131,7 +2133,7 @@
     // Write two drafts on the same side of one patch set.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId,
             filename,
@@ -2145,7 +2147,7 @@
             side,
             commitId,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId,
             filename,
@@ -2159,8 +2161,8 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2170,18 +2172,18 @@
                 commitId, comment1,
                 commitId, comment2))
         .inOrder();
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     // Publish first draft.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment2));
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
@@ -2201,7 +2203,7 @@
     // Write two drafts, one on each side of the patchset.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    Comment baseComment =
+    HumanComment baseComment =
         newComment(
             psId,
             filename,
@@ -2215,7 +2217,7 @@
             (short) 0,
             commitId1,
             false);
-    Comment psComment =
+    HumanComment psComment =
         newComment(
             psId,
             filename,
@@ -2230,8 +2232,8 @@
             commitId2,
             false);
 
-    update.putComment(Comment.Status.DRAFT, baseComment);
-    update.putComment(Comment.Status.DRAFT, psComment);
+    update.putComment(HumanComment.Status.DRAFT, baseComment);
+    update.putComment(HumanComment.Status.DRAFT, psComment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2240,19 +2242,19 @@
             ImmutableListMultimap.of(
                 commitId1, baseComment,
                 commitId2, psComment));
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     // Publish both comments.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
 
-    update.putComment(Comment.Status.PUBLISHED, baseComment);
-    update.putComment(Comment.Status.PUBLISHED, psComment);
+    update.putComment(HumanComment.Status.PUBLISHED, baseComment);
+    update.putComment(HumanComment.Status.PUBLISHED, psComment);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId1, baseComment,
@@ -2271,7 +2273,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             filename,
@@ -2286,7 +2288,7 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.DRAFT, comment);
+    update.putComment(HumanComment.Status.DRAFT, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2316,7 +2318,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2331,7 +2333,7 @@
             commitId1,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -2339,7 +2341,7 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps2,
             filename,
@@ -2354,7 +2356,7 @@
             commitId2,
             false);
     update.setPatchSetId(ps2);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2384,7 +2386,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment =
+    HumanComment comment =
         newComment(
             ps1,
             filename,
@@ -2398,7 +2400,7 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(repo.exactRef(changeMetaRef(c.getId()))).isNotNull();
@@ -2417,7 +2419,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment draft =
+    HumanComment draft =
         newComment(
             ps1,
             filename,
@@ -2431,7 +2433,7 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.DRAFT, draft);
+    update.putComment(HumanComment.Status.DRAFT, draft);
     update.commit();
 
     String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
@@ -2439,7 +2441,7 @@
     assertThat(old).isNotNull();
 
     update = newUpdate(c, otherUser);
-    Comment pub =
+    HumanComment pub =
         newComment(
             ps1,
             filename,
@@ -2453,7 +2455,7 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.PUBLISHED, pub);
+    update.putComment(HumanComment.Status.PUBLISHED, pub);
     update.commit();
 
     assertThat(exactRefAllUsers(draftRef)).isEqualTo(old);
@@ -2469,7 +2471,7 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "filename",
@@ -2484,10 +2486,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
@@ -2501,7 +2503,7 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "filename",
@@ -2516,10 +2518,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
@@ -2540,7 +2542,7 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2554,7 +2556,7 @@
             side,
             commitId1,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps2,
             filename,
@@ -2568,23 +2570,23 @@
             side,
             commitId2,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments()).hasSize(2);
+    assertThat(notes.getHumanComments()).hasSize(2);
   }
 
   @Test
@@ -2598,7 +2600,7 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             "file1",
@@ -2612,7 +2614,7 @@
             side,
             commitId1,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps1,
             "file2",
@@ -2626,23 +2628,23 @@
             side,
             commitId1,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(commitId1))
         .containsExactly(comment1, comment2);
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
+    assertThat(notes.getHumanComments().get(commitId1)).containsExactly(comment2);
   }
 
   @Test
@@ -2671,7 +2673,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             "file1",
@@ -2685,7 +2687,7 @@
             side,
             commitId1,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps1,
             "file2",
@@ -2699,8 +2701,8 @@
             side,
             commitId1,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     String refName = refsDraftComments(c.getId(), otherUserId);
@@ -2708,7 +2710,7 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
     assertThat(exactRefAllUsers(refName)).isNotNull();
     assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
@@ -2730,11 +2732,11 @@
     // Zombie comment is filtered out of drafts via ChangeNotes.
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
+    assertThat(notes.getHumanComments().get(commitId1)).containsExactly(comment2);
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     // Updating an unrelated comment causes the zombie comment to get fixed up.
@@ -2748,7 +2750,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     ChangeUpdate update1 = newUpdate(c, otherUser);
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             c.currentPatchSetId(),
             "filename",
@@ -2762,10 +2764,10 @@
             (short) 1,
             commitId,
             false);
-    update1.putComment(Comment.Status.PUBLISHED, comment1);
+    update1.putComment(HumanComment.Status.PUBLISHED, comment1);
 
     ChangeUpdate update2 = newUpdate(c, otherUser);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             c.currentPatchSetId(),
             "filename",
@@ -2779,7 +2781,7 @@
             (short) 1,
             commitId,
             false);
-    update2.putComment(Comment.Status.PUBLISHED, comment2);
+    update2.putComment(HumanComment.Status.PUBLISHED, comment2);
 
     try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
       manager.add(update1);
@@ -2788,7 +2790,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<Comment> comments = notes.getComments().get(commitId);
+    List<HumanComment> comments = notes.getHumanComments().get(commitId);
     assertThat(comments).hasSize(2);
     assertThat(comments.get(0).message).isEqualTo("comment 1");
     assertThat(comments.get(1).message).isEqualTo("comment 2");
@@ -2815,14 +2817,14 @@
     int numMessages = notes.getChangeMessages().size();
     int numPatchSets = notes.getPatchSets().size();
     int numApprovals = notes.getApprovals().size();
-    int numComments = notes.getComments().size();
+    int numComments = notes.getHumanComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(PatchSet.id(c.getId(), c.currentPatchSetId().get() + 1));
     update.setChangeMessage("Should be ignored");
     update.putApproval("Code-Review", (short) 2);
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Comment comment =
+    HumanComment comment =
         newComment(
             update.getPatchSetId(),
             "filename",
@@ -2836,14 +2838,14 @@
             (short) 1,
             ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getChangeMessages()).hasSize(numMessages);
     assertThat(notes.getPatchSets()).hasSize(numPatchSets);
     assertThat(notes.getApprovals()).hasSize(numApprovals);
-    assertThat(notes.getComments()).hasSize(numComments);
+    assertThat(notes.getHumanComments()).hasSize(numComments);
   }
 
   @Test
@@ -3144,6 +3146,34 @@
   }
 
   @Test
+  public void resetCherryPickOf() throws Exception {
+    Change destinationChange = newChange();
+    Change cherryPickChange = newChange();
+    assertThat(newNotes(destinationChange).getChange().getCherryPickOf()).isNull();
+
+    ChangeUpdate update = newUpdate(destinationChange, changeOwner);
+    update.setCherryPickOf(
+        cherryPickChange.currentPatchSetId().getCommaSeparatedChangeAndPatchSetId());
+    update.commit();
+    assertThat(newNotes(destinationChange).getChange().getCherryPickOf())
+        .isEqualTo(cherryPickChange.currentPatchSetId());
+
+    update = newUpdate(destinationChange, changeOwner);
+    update.resetCherryPickOf();
+    update.commit();
+    assertThat(newNotes(destinationChange).getChange().getCherryPickOf()).isNull();
+
+    // Can set again after reset.
+    cherryPickChange = newChange();
+    update = newUpdate(destinationChange, changeOwner);
+    update.setCherryPickOf(
+        cherryPickChange.currentPatchSetId().getCommaSeparatedChangeAndPatchSetId());
+    update.commit();
+    assertThat(newNotes(destinationChange).getChange().getCherryPickOf())
+        .isEqualTo(cherryPickChange.currentPatchSetId());
+  }
+
+  @Test
   public void updateCount() throws Exception {
     Change c = newChange();
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(1);
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
new file mode 100644
index 0000000..fa05adc
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -0,0 +1,192 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class ChangeUpdateTest extends AbstractChangeNotesTest {
+
+  @Test
+  public void bypassMaxUpdatesShouldBeTrueWhenChangingAttentionSetOnly() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isTrue();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeTrueWhenClosingChange() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.setStatus(Change.Status.ABANDONED);
+
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isTrue();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenNotAbandoningChangeAndNotChangingAttentionSetOnly()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenCommentsAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    // Add a comment
+    RevCommit commit = tr.commit().message("PS2").create();
+    update.putComment(
+        HumanComment.Status.PUBLISHED,
+        newComment(
+            c.currentPatchSetId(),
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            TimeUtil.nowTs(),
+            "Comment",
+            (short) 1,
+            commit,
+            false));
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenReviewersAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.putReviewer(otherUserId, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenReviewersByEmailAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.putReviewerByEmail(Address.create("anyEmail@mail.com"), ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenWIPAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.setWorkInProgress(true);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenNonWIPAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    addToAttentionSet(update);
+    update.setWorkInProgress(false);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeTrueWhenAbandoningAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.setStatus(Change.Status.ABANDONED);
+    addToAttentionSet(update);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isTrue();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenVotingAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    update.putApproval("Code-Review", (short) 1);
+    addToAttentionSet(update);
+    update.commit();
+
+    assertThat(update.bypassMaxUpdates()).isFalse();
+  }
+
+  @Test
+  public void bypassMaxUpdatesShouldBeFalseWhenDeletingVotesAndChangesToAttentionSetCoexist()
+      throws Exception {
+    Change c = newChange();
+    ChangeUpdate updateWithVote = newUpdate(c, changeOwner);
+    updateWithVote.putApproval("Code-Review", (short) 1);
+    updateWithVote.commit();
+
+    updateWithVote.removeApproval("Code-Review");
+    addToAttentionSet(updateWithVote);
+
+    assertThat(updateWithVote.bypassMaxUpdates()).isFalse();
+  }
+
+  private void addToAttentionSet(ChangeUpdate update) {
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(
+            otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index c2620dc..2c1348c 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import java.sql.Timestamp;
@@ -151,7 +152,7 @@
   @Test
   public void newAdapterRoundTripOfWholeComment() {
     Comment c =
-        new Comment(
+        new HumanComment(
             new Comment.Key("uuid", "filename", 1),
             Account.id(100),
             NON_DST_TS,
@@ -165,7 +166,7 @@
     String json = gson.toJson(c);
     assertThat(json).contains("\"writtenOn\": \"" + NON_DST_STR_TRUNC + "\",");
 
-    Comment result = gson.fromJson(json, Comment.class);
+    Comment result = gson.fromJson(json, HumanComment.class);
     // Round-trip lossily truncates ms, but that's ok.
     assertThat(result.writtenOn).isEqualTo(NON_DST_TS_TRUNC);
     result.writtenOn = NON_DST_TS;
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 6a090c1..6cfd9f2d 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -20,9 +20,9 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 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.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
@@ -45,7 +45,6 @@
     update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
-
     RevCommit commit = parseCommit(update.getResult());
     assertBodyEquals(
         "Update patch set 1\n"
@@ -62,7 +61,8 @@
             + "Reviewer: Gerrit User 1 <1@gerrit>\n"
             + "CC: Gerrit User 2 <2@gerrit>\n"
             + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n",
+            + "Label: Verified=+1\n"
+            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
@@ -245,7 +245,8 @@
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n",
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n"
+            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
         update.getResult());
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
index bf49884..041366c 100644
--- a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.util.time.TimeUtil;
 import org.eclipse.jgit.lib.ObjectId;
@@ -31,7 +31,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(c.currentPatchSetId());
-    update.putComment(Comment.Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.PUBLISHED, comment(c.currentPatchSetId()));
     update.commit();
 
     assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
@@ -44,13 +44,13 @@
     ChangeUpdate update = newUpdate(c, otherUser);
 
     update.setPatchSetId(c.currentPatchSetId());
-    update.putComment(Comment.Status.DRAFT, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.DRAFT, comment(c.currentPatchSetId()));
     update.commit();
     assertThat(newNotes(c).getDraftComments(otherUserId)).hasSize(1);
     assertableFanOutExecutor.assertInteractions(0);
 
     update = newUpdate(c, otherUser);
-    update.putComment(Comment.Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.PUBLISHED, comment(c.currentPatchSetId()));
     update.commit();
 
     assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
@@ -63,7 +63,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(c.currentPatchSetId());
-    update.putComment(Comment.Status.DRAFT, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.DRAFT, comment(c.currentPatchSetId()));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -80,7 +80,7 @@
     assertableFanOutExecutor.assertInteractions(0);
   }
 
-  private Comment comment(PatchSet.Id psId) {
+  private HumanComment comment(PatchSet.Id psId) {
     return newComment(
         psId,
         "filename",
diff --git a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
new file mode 100644
index 0000000..507b71f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
@@ -0,0 +1,270 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+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.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Test;
+
+public class OpenRepoTest extends AbstractChangeNotesTest {
+
+  private final Optional<Integer> NO_UPDATES_AT_ALL = Optional.of(0);
+  private final Optional<Integer> ONLY_ONE_UPDATE = Optional.of(1);
+  private final Optional<Integer> ONLY_TWO_UPDATES = Optional.of(2);
+  private final Optional<Integer> MAX_PATCH_SETS = Optional.empty();
+
+  private FakeChainedReceiveCommands fakeChainedReceiveCommands;
+
+  @Override
+  public void setUpTestEnvironment() throws Exception {
+    super.setUpTestEnvironment();
+    fakeChainedReceiveCommands = new FakeChainedReceiveCommands(repo);
+  }
+
+  @Test
+  public void throwExceptionWhenExceedingMaxUpdatesLimit() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void allowExceedingLimitWhenAttentionSetUpdateOnly() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.NEW);
+
+      addToAttentionSet(update);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void allowExceedingLimitWhenChangeIsSubmitted() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.merge(
+          new SubmissionId(c),
+          ImmutableList.of(
+              submitRecord(
+                  "NOT_READY",
+                  null,
+                  submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                  submitLabel("Alternative-Code-Review", "NEED", null))));
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void allowExceedingLimitWhenChangeIsAbandoned() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setStatus(Change.Status.ABANDONED);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
+
+      openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void attentionSetUpdateShouldNotContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      openRepo.addUpdates(changeUpdates, ONLY_TWO_UPDATES, MAX_PATCH_SETS);
+
+      assertThat(fakeChainedReceiveCommands.commands.size()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void attentionSetAndReviewerUpdateShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+      update1.putReviewer(otherUserId, ReviewerStateInternal.REVIEWER);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void attentionSetAndReviewerByEmailUpdateShouldContributeToOperationsCount()
+      throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+      update1.putReviewerByEmail(
+          Address.create("anyEmail@mail.com"), ReviewerStateInternal.REVIEWER);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void attentionSetAndWIPUpdateToTrueShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+      update1.setWorkInProgress(true);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void attentionSetAndWIPUpdateToFalseShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update1 = newUpdateForNewChange(c1, changeOwner);
+      addToAttentionSet(update1);
+      update1.setWorkInProgress(false);
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS));
+    }
+  }
+
+  @Test
+  public void normalChangeShouldContributeToOperationsCount() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change c1 = newChange();
+
+      ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
+      update2.setStatus(Change.Status.NEW);
+
+      ListMultimap<String, ChangeUpdate> changeUpdates =
+          new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
+
+      assertThrows(
+          LimitExceededException.class,
+          () -> openRepo.addUpdates(changeUpdates, ONLY_ONE_UPDATE, MAX_PATCH_SETS));
+    }
+  }
+
+  private void addToAttentionSet(ChangeUpdate update) {
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(
+            otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+  }
+
+  private static class FakeChainedReceiveCommands extends ChainedReceiveCommands {
+    Map<String, ReceiveCommand> commands = new HashMap<>();
+
+    public FakeChainedReceiveCommands(Repository repo) {
+      super(repo);
+    }
+
+    @Override
+    public void add(ReceiveCommand cmd) {
+      commands.put(cmd.getRefName(), cmd);
+    }
+  }
+
+  private OpenRepo openRepo() {
+    return new OpenRepo(repo, rw, null, fakeChainedReceiveCommands, false);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
index a768eaf..0c9f731 100644
--- a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -91,27 +91,37 @@
     assertThat(s.acquireCount).isEqualTo(0);
 
     assertThat(s.next()).isEqualTo(1);
+    assertThat(s.last()).isEqualTo(1);
     assertThat(s.acquireCount).isEqualTo(1);
     assertThat(s.next()).isEqualTo(2);
+    assertThat(s.last()).isEqualTo(2);
     assertThat(s.acquireCount).isEqualTo(1);
     assertThat(s.next()).isEqualTo(3);
+    assertThat(s.last()).isEqualTo(3);
     assertThat(s.acquireCount).isEqualTo(1);
 
     assertThat(s.next()).isEqualTo(4);
+    assertThat(s.last()).isEqualTo(4);
     assertThat(s.acquireCount).isEqualTo(2);
     assertThat(s.next()).isEqualTo(5);
+    assertThat(s.last()).isEqualTo(5);
     assertThat(s.acquireCount).isEqualTo(2);
     assertThat(s.next()).isEqualTo(6);
+    assertThat(s.last()).isEqualTo(6);
     assertThat(s.acquireCount).isEqualTo(2);
 
     assertThat(s.next()).isEqualTo(7);
+    assertThat(s.last()).isEqualTo(7);
     assertThat(s.acquireCount).isEqualTo(3);
     assertThat(s.next()).isEqualTo(8);
+    assertThat(s.last()).isEqualTo(8);
     assertThat(s.acquireCount).isEqualTo(3);
     assertThat(s.next()).isEqualTo(9);
+    assertThat(s.last()).isEqualTo(9);
     assertThat(s.acquireCount).isEqualTo(3);
 
     assertThat(s.next()).isEqualTo(10);
+    assertThat(s.last()).isEqualTo(10);
     assertThat(s.acquireCount).isEqualTo(4);
   }
 
@@ -127,6 +137,8 @@
     assertThat(s2.next()).isEqualTo(5);
     assertThat(s1.next()).isEqualTo(3);
     assertThat(s2.next()).isEqualTo(6);
+    assertThat(s1.last()).isEqualTo(3);
+    assertThat(s2.last()).isEqualTo(6);
 
     // s2 acquires 7-9; s1 acquires 10-12.
     assertThat(s2.next()).isEqualTo(7);
@@ -135,6 +147,8 @@
     assertThat(s1.next()).isEqualTo(11);
     assertThat(s2.next()).isEqualTo(9);
     assertThat(s1.next()).isEqualTo(12);
+    assertThat(s1.last()).isEqualTo(12);
+    assertThat(s2.last()).isEqualTo(9);
   }
 
   @Test
@@ -284,48 +298,61 @@
   }
 
   @Test
-  public void nextWithCountOneCaller() throws Exception {
+  public void nextWithCountAndLastByOneCaller() throws Exception {
     RepoSequence s = newSequence("id", 1, 3);
     assertThat(s.next(2)).containsExactly(1, 2).inOrder();
     assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(s.last()).isEqualTo(2);
     assertThat(s.next(2)).containsExactly(3, 4).inOrder();
     assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.last()).isEqualTo(4);
     assertThat(s.next(2)).containsExactly(5, 6).inOrder();
     assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.last()).isEqualTo(6);
 
     assertThat(s.next(3)).containsExactly(7, 8, 9).inOrder();
     assertThat(s.acquireCount).isEqualTo(3);
+    assertThat(s.last()).isEqualTo(9);
     assertThat(s.next(3)).containsExactly(10, 11, 12).inOrder();
     assertThat(s.acquireCount).isEqualTo(4);
+    assertThat(s.last()).isEqualTo(12);
     assertThat(s.next(3)).containsExactly(13, 14, 15).inOrder();
     assertThat(s.acquireCount).isEqualTo(5);
+    assertThat(s.last()).isEqualTo(15);
 
     assertThat(s.next(7)).containsExactly(16, 17, 18, 19, 20, 21, 22).inOrder();
     assertThat(s.acquireCount).isEqualTo(6);
+    assertThat(s.last()).isEqualTo(22);
     assertThat(s.next(7)).containsExactly(23, 24, 25, 26, 27, 28, 29).inOrder();
     assertThat(s.acquireCount).isEqualTo(7);
+    assertThat(s.last()).isEqualTo(29);
     assertThat(s.next(7)).containsExactly(30, 31, 32, 33, 34, 35, 36).inOrder();
     assertThat(s.acquireCount).isEqualTo(8);
+    assertThat(s.last()).isEqualTo(36);
   }
 
   @Test
-  public void nextWithCountMultipleCallers() throws Exception {
+  public void nextWithCountAndLastByMultipleCallers() throws Exception {
     RepoSequence s1 = newSequence("id", 1, 3);
     RepoSequence s2 = newSequence("id", 1, 4);
 
     assertThat(s1.next(2)).containsExactly(1, 2).inOrder();
+    assertThat(s1.last()).isEqualTo(2);
     assertThat(s1.acquireCount).isEqualTo(1);
 
     // s1 hasn't exhausted its last batch.
     assertThat(s2.next(2)).containsExactly(4, 5).inOrder();
+    assertThat(s2.last()).isEqualTo(5);
     assertThat(s2.acquireCount).isEqualTo(1);
 
     // s1 acquires again to cover this request, plus a whole new batch.
     assertThat(s1.next(3)).containsExactly(3, 8, 9);
+    assertThat(s1.last()).isEqualTo(9);
     assertThat(s1.acquireCount).isEqualTo(2);
 
     // s2 hasn't exhausted its last batch, do so now.
     assertThat(s2.next(2)).containsExactly(6, 7);
+    assertThat(s2.last()).isEqualTo(7);
     assertThat(s2.acquireCount).isEqualTo(1);
   }
 
diff --git a/javatests/com/google/gerrit/server/patch/MagicFileTest.java b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
new file mode 100644
index 0000000..93928f0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
@@ -0,0 +1,395 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import java.util.Date;
+import java.util.TimeZone;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class MagicFileTest {
+
+  private final GitRepositoryManager repositoryManager = new InMemoryRepositoryManager();
+
+  @Test
+  public void magicFileContentIsBuiltCorrectly() {
+    MagicFile magicFile =
+        MagicFile.builder()
+            .generatedContent("Generated 1\n")
+            .modifiableContent("Modifiable 1\n")
+            .build();
+
+    assertThat(magicFile.getFileContent()).isEqualTo("Generated 1\nModifiable 1\n");
+  }
+
+  @Test
+  public void generatedContentMayBeEmpty() {
+    MagicFile magicFile = MagicFile.builder().modifiableContent("Modifiable 1\n").build();
+
+    assertThat(magicFile.getFileContent()).isEqualTo("Modifiable 1\n");
+  }
+
+  @Test
+  public void modifiableContentMayBeEmpty() {
+    MagicFile magicFile = MagicFile.builder().generatedContent("Generated 1\n").build();
+
+    assertThat(magicFile.getFileContent()).isEqualTo("Generated 1\n");
+  }
+
+  @Test
+  public void generatedContentAlwaysHasNewlineAtEnd() {
+    MagicFile magicFile = MagicFile.builder().generatedContent("Generated 1").build();
+
+    assertThat(magicFile.generatedContent()).isEqualTo("Generated 1\n");
+  }
+
+  @Test
+  public void modifiableContentAlwaysHasNewlineAtEnd() {
+    MagicFile magicFile = MagicFile.builder().modifiableContent("Modifiable 1").build();
+
+    assertThat(magicFile.modifiableContent()).isEqualTo("Modifiable 1\n");
+  }
+
+  @Test
+  public void startOfModifiableContentIsIndicatedCorrectlyWhenGeneratedContentIsPresent() {
+    MagicFile magicFile =
+        MagicFile.builder()
+            .generatedContent("Line 1\nLine2\n")
+            .modifiableContent("Line 3\n")
+            .build();
+
+    // Generated content. -> Modifiable content starts in line 3.
+    assertThat(magicFile.getStartLineOfModifiableContent()).isEqualTo(3);
+  }
+
+  @Test
+  public void startOfModifiableContentIsIndicatedCorrectlyWhenGeneratedContentIsEmpty() {
+    MagicFile magicFile = MagicFile.builder().modifiableContent("Line 1\n").build();
+
+    assertThat(magicFile.getStartLineOfModifiableContent()).isEqualTo(1);
+  }
+
+  @Test
+  public void commitMessageFileOfRootCommitContainsCorrectContent() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+
+      Instant authorTime =
+          LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
+      PersonIdent author =
+          new PersonIdent(
+              "Alfred",
+              "alfred@example.com",
+              Date.from(authorTime),
+              TimeZone.getTimeZone(ZoneOffset.UTC));
+
+      Instant committerTime =
+          LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
+      PersonIdent committer =
+          new PersonIdent(
+              "Luise",
+              "luise@example.com",
+              Date.from(committerTime),
+              TimeZone.getTimeZone(ZoneOffset.UTC));
+
+      ObjectId commit =
+          testRepo
+              .commit()
+              .message("Subject line\n\nFurther explanations.\n")
+              .author(author)
+              .committer(committer)
+              .noParents()
+              .create();
+
+      MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+
+      // The content of the commit message file must not change over time as existing comments
+      // would otherwise refer to different content than when they were originally left.
+      // -> Keep this format stable over time.
+      assertThat(commitMessageFile.getFileContent())
+          .isEqualTo(
+              "Author:     Alfred <alfred@example.com>\n"
+                  + "AuthorDate: 2020-04-23 19:30:27 +0000\n"
+                  + "Commit:     Luise <luise@example.com>\n"
+                  + "CommitDate: 2021-01-06 05:12:55 +0000\n"
+                  + "\n"
+                  + "Subject line\n"
+                  + "\n"
+                  + "Further explanations.\n");
+    }
+  }
+
+  @Test
+  public void commitMessageFileOfNonMergeCommitContainsCorrectContent() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+
+      Instant authorTime =
+          LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
+      PersonIdent author =
+          new PersonIdent(
+              "Alfred",
+              "alfred@example.com",
+              Date.from(authorTime),
+              TimeZone.getTimeZone(ZoneOffset.UTC));
+
+      Instant committerTime =
+          LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
+      PersonIdent committer =
+          new PersonIdent(
+              "Luise",
+              "luise@example.com",
+              Date.from(committerTime),
+              TimeZone.getTimeZone(ZoneOffset.UTC));
+
+      RevCommit parent =
+          testRepo.commit().message("Parent subject\n\nParent further details.").create();
+      ObjectId commit =
+          testRepo
+              .commit()
+              .message("Subject line\n\nFurther explanations.\n")
+              .author(author)
+              .committer(committer)
+              .parent(parent)
+              .create();
+
+      MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+
+      // The content of the commit message file must not change over time as existing comments
+      // would otherwise refer to different content than when they were originally left.
+      // -> Keep this format stable over time.
+      assertThat(commitMessageFile.getFileContent())
+          .isEqualTo(
+              String.format(
+                  "Parent:     %s (Parent subject)\n"
+                      + "Author:     Alfred <alfred@example.com>\n"
+                      + "AuthorDate: 2020-04-23 19:30:27 +0000\n"
+                      + "Commit:     Luise <luise@example.com>\n"
+                      + "CommitDate: 2021-01-06 05:12:55 +0000\n"
+                      + "\n"
+                      + "Subject line\n"
+                      + "\n"
+                      + "Further explanations.\n",
+                  parent.name().substring(0, 8)));
+    }
+  }
+
+  @Test
+  public void commitMessageFileOfMergeCommitContainsCorrectContent() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+
+      Instant authorTime =
+          LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
+      PersonIdent author =
+          new PersonIdent(
+              "Alfred",
+              "alfred@example.com",
+              Date.from(authorTime),
+              TimeZone.getTimeZone(ZoneOffset.UTC));
+
+      Instant committerTime =
+          LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
+      PersonIdent committer =
+          new PersonIdent(
+              "Luise",
+              "luise@example.com",
+              Date.from(committerTime),
+              TimeZone.getTimeZone(ZoneOffset.UTC));
+
+      RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
+      RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
+      ObjectId commit =
+          testRepo
+              .commit()
+              .message("Subject line\n\nFurther explanations.\n")
+              .author(author)
+              .committer(committer)
+              .parent(parent1)
+              .parent(parent2)
+              .create();
+
+      MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+
+      // The content of the commit message file must not change over time as existing comments
+      // would otherwise refer to different content than when they were originally left.
+      // -> Keep this format stable over time.
+      String expectedContent =
+          String.format(
+              "Merge Of:   %s (Parent 1)\n"
+                  + "            %s (Parent 2)\n"
+                  + "Author:     Alfred <alfred@example.com>\n"
+                  + "AuthorDate: 2020-04-23 19:30:27 +0000\n"
+                  + "Commit:     Luise <luise@example.com>\n"
+                  + "CommitDate: 2021-01-06 05:12:55 +0000\n"
+                  + "\n"
+                  + "Subject line\n"
+                  + "\n"
+                  + "Further explanations.\n",
+              parent1.name().substring(0, 8), parent2.name().substring(0, 8));
+      assertThat(commitMessageFile.getFileContent()).isEqualTo(expectedContent);
+    }
+  }
+
+  @Test
+  public void commitMessageFileEndsWithEmptyLineIfCommitMessageIsEmpty() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+      RevCommit commit = testRepo.commit().message("").create();
+
+      MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+      assertThat(commitMessageFile.getFileContent()).endsWith("\n\n");
+    }
+  }
+
+  @Test
+  public void commitMessageFileContainsFullCommitMessageAsModifiablePart() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+      RevCommit commit =
+          testRepo.commit().message("Subject line\n\nFurther explanations.\n").create();
+
+      MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+      assertThat(commitMessageFile.modifiableContent())
+          .isEqualTo("Subject line\n\nFurther explanations.\n");
+    }
+  }
+
+  @Test
+  public void mergeListFileContainsCorrectContentForDiffAgainstFirstParent() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+      RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
+      RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
+      ObjectId commit = testRepo.commit().parent(parent1).parent(parent2).create();
+
+      MagicFile mergeListFile =
+          MagicFile.forMergeList(ComparisonType.againstParent(1), objectReader, commit);
+
+      // The content of the merge list file must not change over time as existing comments
+      // would otherwise refer to different content than when they were originally left.
+      // -> Keep this format stable over time.
+      String expectedContent =
+          String.format("Merge List:\n\n* %s Parent 2\n", parent2.name().substring(0, 8));
+      assertThat(mergeListFile.getFileContent()).isEqualTo(expectedContent);
+    }
+  }
+
+  @Test
+  public void mergeListFileContainsCorrectContentForDiffAgainstSecondParent() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+      RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
+      RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
+      ObjectId commit = testRepo.commit().parent(parent1).parent(parent2).create();
+
+      MagicFile mergeListFile =
+          MagicFile.forMergeList(ComparisonType.againstParent(2), objectReader, commit);
+
+      // The content of the merge list file must not change over time as existing comments
+      // would otherwise refer to different content than when they were originally left.
+      // -> Keep this format stable over time.
+      String expectedContent =
+          String.format("Merge List:\n\n* %s Parent 1\n", parent1.name().substring(0, 8));
+      assertThat(mergeListFile.getFileContent()).isEqualTo(expectedContent);
+    }
+  }
+
+  @Test
+  public void mergeListFileContainsCorrectContentForDiffAgainstAutoMerge() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+      RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
+      RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
+      ObjectId commit = testRepo.commit().parent(parent1).parent(parent2).create();
+
+      MagicFile mergeListFile =
+          MagicFile.forMergeList(ComparisonType.againstAutoMerge(), objectReader, commit);
+
+      // When auto-merge is chosen, we fall back to the diff against the first parent.
+      String expectedContent =
+          String.format("Merge List:\n\n* %s Parent 2\n", parent2.name().substring(0, 8));
+      assertThat(mergeListFile.getFileContent()).isEqualTo(expectedContent);
+    }
+  }
+
+  @Test
+  public void mergeListFileIsEmptyForRootCommit() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+      ObjectId commit = testRepo.commit().noParents().create();
+
+      MagicFile mergeListFile =
+          MagicFile.forMergeList(ComparisonType.againstParent(1), objectReader, commit);
+
+      assertThat(mergeListFile.getFileContent()).isEmpty();
+    }
+  }
+
+  @Test
+  public void mergeListFileIsEmptyForNonMergeCommit() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+      RevCommit parent = testRepo.commit().message("Parent 1\n").create();
+      ObjectId commit = testRepo.commit().parent(parent).create();
+
+      MagicFile mergeListFile =
+          MagicFile.forMergeList(ComparisonType.againstParent(1), objectReader, commit);
+
+      assertThat(mergeListFile.getFileContent()).isEmpty();
+    }
+  }
+
+  @Test
+  public void mergeListFileDoesNotHaveAModifiablePart() throws Exception {
+    try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+        TestRepository<Repository> testRepo = new TestRepository<>(repository);
+        ObjectReader objectReader = repository.newObjectReader()) {
+      RevCommit parent1 = testRepo.commit().message("Parent 1\n").create();
+      RevCommit parent2 = testRepo.commit().message("Parent 2\n").create();
+      ObjectId commit = testRepo.commit().parent(parent1).parent(parent2).create();
+
+      MagicFile mergeListFile =
+          MagicFile.forMergeList(ComparisonType.againstParent(1), objectReader, commit);
+
+      // Nothing in the merge list file represents something users may modify.
+      assertThat(mergeListFile.modifiableContent()).isEmpty();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/patch/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
index e224191a..56adefa 100644
--- a/javatests/com/google/gerrit/server/patch/PatchListTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListTest.java
@@ -26,16 +26,30 @@
 import org.junit.Test;
 
 public class PatchListTest {
+
   @Test
   public void fileOrder() {
     String[] names = {
-      "zzz", "def/g", "/!xxx", "abc", Patch.MERGE_LIST, "qrx", Patch.COMMIT_MSG,
+      "zzz",
+      "def/g",
+      "/!xxx",
+      "abc",
+      Patch.MERGE_LIST,
+      "qrx",
+      Patch.COMMIT_MSG,
+      Patch.PATCHSET_LEVEL
     };
     String[] want = {
-      Patch.COMMIT_MSG, Patch.MERGE_LIST, "/!xxx", "abc", "def/g", "qrx", "zzz",
+      Patch.COMMIT_MSG,
+      Patch.MERGE_LIST,
+      Patch.PATCHSET_LEVEL,
+      "/!xxx",
+      "abc",
+      "def/g",
+      "qrx",
+      "zzz",
     };
-
-    Arrays.sort(names, 0, names.length, PatchList::comparePaths);
+    Arrays.sort(names, 0, names.length, PatchList.FILE_PATH_CMP);
     assertThat(names).isEqualTo(want);
   }
 
@@ -48,7 +62,7 @@
       Patch.COMMIT_MSG, "/!xxx", "abc", "def/g", "qrx", "zzz",
     };
 
-    Arrays.sort(names, 0, names.length, PatchList::comparePaths);
+    Arrays.sort(names, 0, names.length, PatchList.FILE_PATH_CMP);
     assertThat(names).isEqualTo(want);
   }
 
diff --git a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
index 305e81b..6c5eb7a 100644
--- a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
+++ b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.refPermission;
 
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import org.junit.Test;
 
 public class DefaultPermissionsMappingTest {
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index c600844..87db21f 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -23,12 +23,12 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
-import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
-import static com.google.gerrit.common.data.Permission.LABEL;
-import static com.google.gerrit.common.data.Permission.OWNER;
-import static com.google.gerrit.common.data.Permission.PUSH;
-import static com.google.gerrit.common.data.Permission.READ;
-import static com.google.gerrit.common.data.Permission.SUBMIT;
+import static com.google.gerrit.entities.Permission.EDIT_TOPIC_NAME;
+import static com.google.gerrit.entities.Permission.LABEL;
+import static com.google.gerrit.entities.Permission.OWNER;
+import static com.google.gerrit.entities.Permission.PUSH;
+import static com.google.gerrit.entities.Permission.READ;
+import static com.google.gerrit.entities.Permission.SUBMIT;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -39,9 +39,9 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.server.CurrentUser;
@@ -233,7 +233,7 @@
         ProjectConfig allProjectsConfig = projectConfigFactory.create(allProjectsName);
         allProjectsConfig.load(md);
         LabelType cr = TestLabels.codeReview();
-        allProjectsConfig.getLabelSections().put(cr.getName(), cr);
+        allProjectsConfig.upsertLabelType(cr);
         allProjectsConfig.commit(md);
       }
     }
@@ -243,7 +243,7 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(localKey)) {
       ProjectConfig newLocal = projectConfigFactory.create(localKey);
       newLocal.load(md);
-      newLocal.getProject().setParentName(parentKey);
+      newLocal.updateProject(p -> p.setParent(parentKey));
       newLocal.commit(md);
     }
 
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index 5e94daa..1035fe7 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.junit.Assert.assertFalse;
@@ -26,13 +26,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
-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.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
@@ -228,6 +228,7 @@
             .getAllProjects()
             .getConfig()
             .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+            .orElseThrow(() -> new IllegalStateException("access section does not exist"))
             .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
 
     return adminPermission.getRules().stream()
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 518f85d..7d4b7ca 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -23,8 +23,8 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.IOException;
@@ -39,7 +39,7 @@
   private static final String TEXT =
       "# UUID                                  \tGroup Name\n"
           + "#\n"
-          + "d96b998f8a66ff433af50befb975d0e2bb6e0999\tNon-Interactive Users\n"
+          + "d96b998f8a66ff433af50befb975d0e2bb6e0999\tService Users\n"
           + "ebe31c01aec2c9ac3b3c03e87a47450829ff4310\tAdministrators\n";
 
   private GroupList groupList;
@@ -57,13 +57,13 @@
     GroupReference groupReference = groupList.byUUID(uuid);
 
     assertEquals(uuid, groupReference.getUUID());
-    assertEquals("Non-Interactive Users", groupReference.getName());
+    assertEquals("Service Users", groupReference.getName());
   }
 
   @Test
   public void put() {
     AccountGroup.UUID uuid = AccountGroup.uuid("abc");
-    GroupReference groupReference = new GroupReference(uuid, "Hutzliputz");
+    GroupReference groupReference = GroupReference.create(uuid, "Hutzliputz");
 
     groupList.put(uuid, groupReference);
 
@@ -78,7 +78,7 @@
 
     assertEquals(2, result.size());
     AccountGroup.UUID uuid = AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
-    GroupReference expected = new GroupReference(uuid, "Administrators");
+    GroupReference expected = GroupReference.create(uuid, "Administrators");
 
     assertTrue(result.contains(expected));
   }
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 0dd6436..a39821e 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -20,15 +20,18 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ContributorAgreement;
-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.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.StoredCommentLinkInfo;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
@@ -55,6 +58,8 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.junit.Before;
 import org.junit.Rule;
@@ -90,8 +95,8 @@
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   private final GroupReference developers =
-      new GroupReference(AccountGroup.uuid("X"), "Developers");
-  private final GroupReference staff = new GroupReference(AccountGroup.uuid("Y"), "Staff");
+      GroupReference.create(AccountGroup.uuid("X"), "Developers");
+  private final GroupReference staff = GroupReference.create(AccountGroup.uuid("Y"), "Staff");
 
   private SitePaths sitePaths;
   private ProjectConfig.Factory factory;
@@ -302,17 +307,22 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    AccessSection section = cfg.getAccessSection("refs/heads/*");
-    cfg.getAccountsSection()
-        .setSameGroupVisibility(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
-    Permission submit = section.getPermission(Permission.SUBMIT);
-    submit.add(new PermissionRule(cfg.resolve(staff)));
-    ContributorAgreement ca = cfg.getContributorAgreement("Individual");
-    ca.setAccepted(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
+    cfg.upsertAccessSection(
+        "refs/heads/*",
+        section -> {
+          Permission.Builder submit = section.upsertPermission(Permission.SUBMIT);
+          submit.add(PermissionRule.builder(cfg.resolve(staff)));
+        });
+    cfg.setAccountsSection(
+        AccountsSection.create(
+            Collections.singletonList(PermissionRule.create(cfg.resolve(staff)))));
+    ContributorAgreement.Builder ca = cfg.getContributorAgreement("Individual").toBuilder();
+    ca.setAccepted(ImmutableList.of(PermissionRule.create(cfg.resolve(staff))));
     ca.setAutoVerify(null);
-    ca.setMatchProjectsRegexes(null);
-    ca.setExcludeProjectsRegexes(Collections.singletonList("^/theirproject"));
+    ca.setMatchProjectsRegexes(ImmutableList.of());
+    ca.setExcludeProjectsRegexes(ImmutableList.of("^/theirproject"));
     ca.setDescription("A new description");
+    cfg.upsertContributorAgreement(ca.build());
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -361,12 +371,43 @@
   }
 
   @Test
+  public void readExistingBranchOrder() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("project.config", "[branchOrder]\n" + "\tbranch = foo\n" + "\tbranch = bar\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getBranchOrderSection())
+        .isEqualTo(BranchOrderSection.create(ImmutableList.of("foo", "bar")));
+  }
+
+  @Test
+  public void editBranchOrder() throws Exception {
+    RevCommit rev = tr.commit().create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    cfg.setBranchOrderSection(BranchOrderSection.create(ImmutableList.of("foo", "bar")));
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo("[branchOrder]\n" + "\tbranch = foo\n" + "\tbranch = bar\n");
+  }
+
+  @Test
   public void addCommentLink() throws Exception {
     RevCommit rev = tr.commit().create();
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    CommentLinkInfoImpl cm = new CommentLinkInfoImpl("Test", "abc.*", null, "<a>link</a>", true);
+    StoredCommentLinkInfo cm =
+        StoredCommentLinkInfo.builder("Test")
+            .setMatch("abc.*")
+            .setHtml("<a>link</a>")
+            .setEnabled(true)
+            .setOverrideOnly(false)
+            .build();
     cfg.addCommentLinkSection(cm);
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
@@ -389,9 +430,12 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    AccessSection section = cfg.getAccessSection("refs/heads/*");
-    Permission submit = section.getPermission(Permission.SUBMIT);
-    submit.add(new PermissionRule(cfg.resolve(staff)));
+    cfg.upsertAccessSection(
+        "refs/heads/*",
+        section -> {
+          Permission.Builder submit = section.upsertPermission(Permission.SUBMIT);
+          submit.add(PermissionRule.builder(cfg.resolve(staff)));
+        });
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -445,9 +489,12 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    pluginCfg.setString("key1", "updatedValue1");
-    pluginCfg.setStringList("key2", Arrays.asList("updatedValue2a", "updatedValue2b"));
+    cfg.updatePluginConfig(
+        "somePlugin",
+        pluginCfg -> {
+          pluginCfg.setString("key1", "updatedValue1");
+          pluginCfg.setStringList("key2", Arrays.asList("updatedValue2a", "updatedValue2b"));
+        });
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -469,7 +516,7 @@
     ProjectConfig cfg = read(rev);
     PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
     assertThat(pluginCfg.getNames()).hasSize(1);
-    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
+    assertThat(pluginCfg.getGroupReference("key1").get()).isEqualTo(developers);
   }
 
   @Test
@@ -498,11 +545,10 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    assertThat(pluginCfg.getNames()).hasSize(1);
-    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
-
-    pluginCfg.setGroupReference("key1", staff);
+    assertThat(cfg.getPluginConfig("somePlugin").getNames()).hasSize(1);
+    assertThat(cfg.getPluginConfig("somePlugin").getGroupReference("key1").get())
+        .isEqualTo(developers);
+    cfg.updatePluginConfig("somePlugin", pluginCfg -> pluginCfg.setGroupReference("key1", staff));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo("[plugin \"somePlugin\"]\n\tkey1 = " + staff.toConfigValue() + "\n");
@@ -529,12 +575,11 @@
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(
-            new CommentLinkInfoImpl(
-                "bugzilla",
-                "(bug\\s+#?)(\\d+)",
-                "http://bugs.example.com/show_bug.cgi?id=$2",
-                null,
-                null));
+            StoredCommentLinkInfo.builder("bugzilla")
+                .setMatch("(bug\\s+#?)(\\d+)")
+                .setLink("http://bugs.example.com/show_bug.cgi?id=$2")
+                .setOverrideOnly(false)
+                .build());
   }
 
   @Test
@@ -543,7 +588,7 @@
         tr.commit().add("project.config", "[commentlink \"bugzilla\"]\n \tenabled = true").create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
-        .containsExactly(new CommentLinkInfoImpl.Enabled("bugzilla"));
+        .containsExactly(StoredCommentLinkInfo.enabled("bugzilla"));
   }
 
   @Test
@@ -554,7 +599,28 @@
             .create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
-        .containsExactly(new CommentLinkInfoImpl.Disabled("bugzilla"));
+        .containsExactly(StoredCommentLinkInfo.disabled("bugzilla"));
+  }
+
+  @Test
+  public void readCommentLinksNoHtmlOrLinkAndMissingEnabled() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[commentlink \"bugzilla\"]\n \tlink = http://bugs.example.com/show_bug.cgi?id=$2"
+                    + "\n \tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n")
+            .create();
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getCommentLinkSections())
+        .containsExactly(
+            StoredCommentLinkInfo.builder("bugzilla")
+                .setMatch("(bug\\s+#?)(\\d+)")
+                .setLink("http://bugs.example.com/show_bug.cgi?id=$2")
+                .build());
+    StoredCommentLinkInfo stored = Iterables.getOnlyElement(cfg.getCommentLinkSections());
+    assertThat(StoredCommentLinkInfo.fromInfo(stored.toInfo(), stored.getEnabled()))
+        .isEqualTo(stored);
   }
 
   @Test
@@ -571,7 +637,7 @@
     assertThat(cfg.getCommentLinkSections()).isEmpty();
     assertThat(cfg.getValidationErrors())
         .containsExactly(
-            new ValidationError(
+            ValidationError.create(
                 "project.config: Invalid pattern \"(bugs{+#?)(d+)\" in commentlink.bugzilla.match: "
                     + "Illegal repetition near index 4\n"
                     + "(bugs{+#?)(d+)\n"
@@ -592,7 +658,7 @@
     assertThat(cfg.getCommentLinkSections()).isEmpty();
     assertThat(cfg.getValidationErrors())
         .containsExactly(
-            new ValidationError(
+            ValidationError.create(
                 "project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
                     + "Raw html replacement not allowed"));
   }
@@ -607,7 +673,7 @@
     assertThat(cfg.getCommentLinkSections()).isEmpty();
     assertThat(cfg.getValidationErrors())
         .containsExactly(
-            new ValidationError(
+            ValidationError.create(
                 "project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
                     + "commentlink.bugzilla must have either link or html"));
   }
@@ -659,7 +725,7 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    cfg.getAccountsSection().setSameGroupVisibility(ImmutableList.of());
+    cfg.setAccountsSection(AccountsSection.create(ImmutableList.of()));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -682,8 +748,9 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    ContributorAgreement section = cfg.getContributorAgreement("Individual");
+    ContributorAgreement.Builder section = cfg.getContributorAgreement("Individual").toBuilder();
     section.setAccepted(ImmutableList.of());
+    cfg.upsertContributorAgreement(section.build());
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -748,14 +815,40 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    pluginCfg.unset("key");
+    cfg.updatePluginConfig("somePlugin", pluginCfg -> pluginCfg.unset("key"));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
             "[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
   }
 
+  @Test
+  public void allProjectsProjectConfigInSitePaths_hashForKeyChangesWithnFileChanges()
+      throws Exception {
+    Path tmp = Files.createTempFile("gerrit_test_", "_site");
+    Files.deleteIfExists(tmp);
+    SitePaths sitePaths = new SitePaths(tmp);
+    byte[] hashedContents =
+        ProjectCacheImpl.allProjectsFileProjectConfigHash(ALL_PROJECTS, sitePaths);
+    assertThat(hashedContents).isEqualTo(new byte[16]); // Empty/absent config
+    FileBasedConfig fileBasedConfig =
+        new FileBasedConfig(
+            sitePaths
+                .etc_dir
+                .resolve(ALL_PROJECTS.get())
+                .resolve(ProjectConfig.PROJECT_CONFIG)
+                .toFile(),
+            FS.DETECTED);
+    fileBasedConfig.setString("plugin", "my-plugin", "key", "value");
+    fileBasedConfig.save();
+    hashedContents = ProjectCacheImpl.allProjectsFileProjectConfigHash(ALL_PROJECTS, sitePaths);
+    assertThat(hashedContents)
+        .isEqualTo(
+            new byte[] {
+              -53, -97, -1, 52, -119, 104, -13, -41, -7, 82, -90, 126, -32, -13, -91, 49
+            });
+  }
+
   private Path writeDefaultAllProjectsConfig(String... lines) throws IOException {
     Path dir = sitePaths.etc_dir.resolve(ALL_PROJECTS.get());
     Files.createDirectories(dir);
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 59f2b6d..f5c9628 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -217,7 +217,7 @@
 
     AccountInfo user5 = newAccountWithEmail("user5", name("user5MixedCase@example.com"));
 
-    assertQuery("notexisting@test.com");
+    assertQuery("notexisting@example.com");
 
     assertQuery(currentUserInfo.email, currentUserInfo);
     assertQuery("email:" + currentUserInfo.email, currentUserInfo);
@@ -253,8 +253,8 @@
 
   @Test
   public void byEmailWithoutModifyAccountCapability() throws Exception {
-    String preferredEmail = name("primary@test.com");
-    String secondaryEmail = name("secondary@test.com");
+    String preferredEmail = name("primary@example.com");
+    String secondaryEmail = name("secondary@example.com");
     AccountInfo user1 = newAccountWithEmail("user1", preferredEmail);
     addEmails(user1, secondaryEmail);
 
@@ -485,11 +485,11 @@
     // sorting by account ID. Use the same fullname for all accounts so that sorting must be done by
     // preferred email.
     AccountInfo userFoo3 =
-        newAccount("user3", "foo-" + appendix, "foo3-" + appendix + "@test.com", true);
+        newAccount("user3", "foo-" + appendix, "foo3-" + appendix + "@example.com", true);
     AccountInfo userFoo1 =
-        newAccount("user1", "foo-" + appendix, "foo1-" + appendix + "@test.com", true);
+        newAccount("user1", "foo-" + appendix, "foo1-" + appendix + "@example.com", true);
     AccountInfo userFoo2 =
-        newAccount("user2", "foo-" + appendix, "foo2-" + appendix + "@test.com", true);
+        newAccount("user2", "foo-" + appendix, "foo2-" + appendix + "@example.com", true);
     assertThat(userFoo3._accountId).isLessThan(userFoo1._accountId);
     assertThat(userFoo1._accountId).isLessThan(userFoo2._accountId);
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 8113f64..88dda0f 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -44,23 +44,22 @@
 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;
-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.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -82,6 +81,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
@@ -197,22 +197,6 @@
 
   private String systemTimeZone;
 
-  // These queries must be kept in sync with PolyGerrit:
-  // polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
-
-  protected static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft";
-  protected static final String DASHBOARD_ASSIGNED_QUERY =
-      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open -is:ignored";
-  protected static final String DASHBOARD_WORK_IN_PROGRESS_QUERY = "is:open owner:${user} is:wip";
-  protected static final String DASHBOARD_OUTGOING_QUERY =
-      "is:open owner:${user} -is:wip -is:ignored";
-  protected static final String DASHBOARD_INCOMING_QUERY =
-      "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user})";
-  protected static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
-      "is:closed -is:ignored (-is:wip OR owner:self) "
-          + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
-          + "OR cc:${user})";
-
   protected abstract Injector createInjector();
 
   @Before
@@ -633,9 +617,14 @@
     PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
     PersonIdent john = new PersonIdent("John", "john@example.com");
     PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
+    Account ua = user.asIdentifiedUser().getAccount();
+    PersonIdent myself = new PersonIdent("I Am", ua.preferredEmail());
+    PersonIdent selfName = new PersonIdent("My Self", "my.self@example.com");
+
     Change change1 = createChange(repo, johnDoe);
     Change change2 = createChange(repo, john);
     Change change3 = createChange(repo, doeSmith);
+    Change change4 = createChange(repo, selfName);
 
     // Only email address.
     assertQuery(searchOperator + "john.doe@example.com", change1);
@@ -651,6 +640,18 @@
     assertQuery(searchOperator + "\"John <john.doe@example.com>\"");
     assertQuery(searchOperator + "\"Doe John <john@example.com>\"");
     assertQuery(searchOperator + "\"Doe John <doe_smith@example.com>\"");
+
+    // Partial name
+    assertQuery(searchOperator + "ohn");
+    assertQuery(searchOperator + "smith", change3);
+
+    // The string 'self' in the name should not be matched
+    assertQuery(searchOperator + "self");
+
+    // ':self' matches a change created with the current user's email address
+    Change change5 = createChange(repo, myself);
+    assertQuery(searchOperator + "me", change5);
+    assertQuery(searchOperator + "self", change5);
   }
 
   private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
@@ -1026,7 +1027,7 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig cfg = projectConfigFactory.create(project);
       cfg.load(md);
-      cfg.getLabelSections().put(verified.getName(), verified);
+      cfg.upsertLabelType(verified);
       cfg.commit(md);
     }
     projectCache.evictAndReindex(project);
@@ -1859,11 +1860,16 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       md.setMessage(String.format("Grant %s on %s", permission, ref));
       ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      PermissionRule rule = new PermissionRule(new GroupReference(groupUUID, groupUUID.get()));
-      rule.setForce(force);
-      p.add(rule);
+      config.upsertAccessSection(
+          ref,
+          s -> {
+            Permission.Builder p = s.upsertPermission(permission);
+            PermissionRule.Builder rule =
+                PermissionRule.builder(GroupReference.create(groupUUID, groupUUID.get()))
+                    .setForce(force);
+            p.add(rule);
+          });
+
       config.commit(md);
       projectCache.evictAndReindex(config.getProject());
     }
@@ -2748,6 +2754,18 @@
     return assertQueryByIds(query.replaceAll("\\$\\{user}", viewedUser), ids);
   }
 
+  protected List<ChangeInfo> assertDashboardQueryWithStart(
+      String viewedUser, String query, int start, DashboardChangeState... expected)
+      throws Exception {
+    Change.Id[] ids = new Change.Id[expected.length];
+    for (int i = 0; i < expected.length; i++) {
+      ids[i] = expected[i].id;
+    }
+    QueryRequest queryRequest = newQuery(query.replaceAll("\\$\\{user}", viewedUser));
+    queryRequest.withStart(start);
+    return assertQueryByIds(queryRequest, ids);
+  }
+
   @Test
   public void dashboardHasUnpublishedDrafts() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
@@ -2762,7 +2780,8 @@
         .draftAndDeleteCommentBy(user.getAccountId())
         .create(repo);
 
-    assertDashboardQuery("self", DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY, hasUnpublishedDraft);
+    assertDashboardQuery(
+        "self", IndexPreloadingUtil.DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY, hasUnpublishedDraft);
   }
 
   @Test
@@ -2786,11 +2805,13 @@
         .assignTo(user.getAccountId())
         .mergeBy(user.getAccountId());
 
-    assertDashboardQuery("self", DASHBOARD_ASSIGNED_QUERY, selfOpenWip, otherOpenWip);
+    assertDashboardQuery(
+        "self", IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, selfOpenWip, otherOpenWip);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
-    assertDashboardQuery(user.getUserName().get(), DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
+    assertDashboardQuery(
+        user.getUserName().get(), IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
   }
 
   @Test
@@ -2804,7 +2825,8 @@
     new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
     new DashboardChangeState(createAccount("other")).wip().create(repo);
 
-    assertDashboardQuery("self", DASHBOARD_WORK_IN_PROGRESS_QUERY, ownedOpenWip);
+    assertDashboardQuery(
+        "self", IndexPreloadingUtil.DASHBOARD_WORK_IN_PROGRESS_QUERY, ownedOpenWip);
   }
 
   @Test
@@ -2822,11 +2844,17 @@
 
     // Viewing one's own dashboard.
     assertDashboardQuery(
-        "self", DASHBOARD_OUTGOING_QUERY, ownedOpenReviewableIgnoredByOther, ownedOpenReviewable);
+        "self",
+        IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
+        ownedOpenReviewableIgnoredByOther,
+        ownedOpenReviewable);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
-    assertDashboardQuery(user.getUserName().get(), DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
+    assertDashboardQuery(
+        user.getUserName().get(),
+        IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
+        ownedOpenReviewable);
   }
 
   @Test
@@ -2858,13 +2886,17 @@
         .create(repo);
 
     // Viewing one's own dashboard.
-    assertDashboardQuery("self", DASHBOARD_INCOMING_QUERY, assignedReviewable, reviewingReviewable);
+    assertDashboardQuery(
+        "self",
+        IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
+        assignedReviewable,
+        reviewingReviewable);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
         user.getUserName().get(),
-        DASHBOARD_INCOMING_QUERY,
+        IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
         assignedReviewableIgnoredByAssignee,
         assignedReviewable,
         reviewingReviewableIgnoredByReviewer,
@@ -2976,7 +3008,7 @@
     // Viewing one's own dashboard.
     assertDashboardQuery(
         "self",
-        DASHBOARD_RECENTLY_CLOSED_QUERY,
+        IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
         abandonedAssigned,
         abandonedReviewing,
         abandonedOwnedWipIgnoredByOther,
@@ -2986,14 +3018,16 @@
         mergedAssigned,
         mergedCced,
         mergedReviewing,
-        mergedOwnedIgnoredByOther,
-        mergedOwned);
+        mergedOwnedIgnoredByOther);
+
+    assertDashboardQueryWithStart(
+        "self", IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY, 10, mergedOwned);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
         user.getUserName().get(),
-        DASHBOARD_RECENTLY_CLOSED_QUERY,
+        IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
         abandonedAssignedWipIgnoredByUser,
         abandonedAssignedWip,
         abandonedAssignedIgnoredByUser,
@@ -3003,7 +3037,12 @@
         abandonedOwned,
         mergedAssignedIgnoredByUser,
         mergedAssigned,
-        mergedCced,
+        mergedCced);
+
+    assertDashboardQueryWithStart(
+        user.getUserName().get(),
+        IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
+        10,
         mergedReviewingIgnoredByUser,
         mergedReviewing,
         mergedOwned);
@@ -3016,7 +3055,7 @@
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
 
-    AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "some reason");
+    AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
     gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
 
     assertQuery("attention:" + user.getUserName().get(), change1);
@@ -3029,16 +3068,17 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change = insert(repo, newChange(repo));
 
-    AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "reason 1");
+    AttentionSetInput input = new AttentionSetInput(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");
+    input = new AttentionSetInput(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).isNotNull();
     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");
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index e5b51e7..0258e5d 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -22,6 +22,7 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/lifecycle",
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
new file mode 100644
index 0000000..d0398e9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -0,0 +1,268 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static java.util.Comparator.comparing;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyShort;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class CommentPorterTest {
+
+  private final ObjectId dummyObjectId =
+      ObjectId.fromString("0123456789012345678901234567890123456789");
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+  @Mock private PatchListCache patchListCache;
+  @Mock private CommentsUtil commentsUtil;
+
+  private int uuidCounter = 0;
+
+  @Test
+  public void commentsAreNotDroppedWhenDiffNotAvailable() throws Exception {
+    Project.NameKey project = Project.nameKey("myProject");
+    Change.Id changeId = Change.id(1);
+    Change change = createChange(project, changeId);
+    PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+    PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+    ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
+
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenThrow(PatchListNotAvailableException.class);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(
+            changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+    assertThat(portedComments).isNotEmpty();
+  }
+
+  @Test
+  public void commentsAreNotDroppedWhenDiffHasUnexpectedError() throws Exception {
+    Project.NameKey project = Project.nameKey("myProject");
+    Change.Id changeId = Change.id(1);
+    Change change = createChange(project, changeId);
+    PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+    PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+    ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
+
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenThrow(IllegalStateException.class);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(
+            changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+    assertThat(portedComments).isNotEmpty();
+  }
+
+  @Test
+  public void commentsAreNotDroppedWhenRetrievingCommitSha1sHasUnexpectedError() {
+    Project.NameKey project = Project.nameKey("myProject");
+    Change.Id changeId = Change.id(1);
+    Change change = createChange(project, changeId);
+    PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+    PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+    ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
+
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenThrow(IllegalStateException.class);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(
+            changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+    assertThat(portedComments).isNotEmpty();
+  }
+
+  @Test
+  public void commentsAreMappedToPatchsetLevelOnDiffError() throws Exception {
+    Project.NameKey project = Project.nameKey("myProject");
+    Change.Id changeId = Change.id(1);
+    Change change = createChange(project, changeId);
+    PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+    PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+    ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
+
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenThrow(IllegalStateException.class);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(
+            changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasFilePath())
+        .containsExactly(Patch.PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void commentsAreStillPortedWhenDiffOfOtherCommentsHasError() throws Exception {
+    Project.NameKey project = Project.nameKey("myProject");
+    Change.Id changeId = Change.id(1);
+    Change change = createChange(project, changeId);
+    PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+    PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+    PatchSet patchset3 = createPatchset(PatchSet.id(changeId, 3));
+    ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2, patchset3);
+
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    // Place the comments on different patchsets to have two different diff requests.
+    HumanComment comment1 = createComment(patchset1.id(), "myFile");
+    HumanComment comment2 = createComment(patchset2.id(), "myFile");
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
+    PatchList emptyDiff = getEmptyDiff();
+    // Throw an exception on the first diff request but return an actual value on the second.
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenThrow(IllegalStateException.class)
+        .thenReturn(emptyDiff);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(
+            changeNotes, patchset3, ImmutableList.of(comment1, comment2), ImmutableList.of());
+
+    // One of the comments should still be ported as usual. -> Keeps its file name as the diff was
+    // empty.
+    assertThat(portedComments).comparingElementsUsing(hasFilePath()).contains("myFile");
+  }
+
+  @Test
+  public void commentsWithInvalidPatchsetsAreIgnored() throws Exception {
+    Project.NameKey project = Project.nameKey("myProject");
+    Change.Id changeId = Change.id(1);
+    Change change = createChange(project, changeId);
+    PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+    PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+    // Leave out patchset 1 (e.g. reserved for draft patchsets in the past).
+    ChangeNotes changeNotes = mockChangeNotes(project, change, patchset2);
+
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
+    PatchList emptyDiff = getEmptyDiff();
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenReturn(emptyDiff);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(
+            changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+    assertThat(portedComments).isEmpty();
+  }
+
+  private Change createChange(Project.NameKey project, Change.Id changeId) {
+    return new Change(
+        Change.key("changeKey"),
+        changeId,
+        Account.id(123),
+        BranchNameKey.create(project, "myBranch"),
+        new Timestamp(12345));
+  }
+
+  private PatchSet createPatchset(PatchSet.Id id) {
+    return PatchSet.builder()
+        .id(id)
+        .commitId(dummyObjectId)
+        .uploader(Account.id(123))
+        .createdOn(new Timestamp(12345))
+        .build();
+  }
+
+  private ChangeNotes mockChangeNotes(
+      Project.NameKey project, Change change, PatchSet... patchsets) {
+    ChangeNotes changeNotes = mock(ChangeNotes.class);
+    when(changeNotes.getProjectName()).thenReturn(project);
+    when(changeNotes.getChange()).thenReturn(change);
+    when(changeNotes.getChangeId()).thenReturn(change.getId());
+    ImmutableSortedMap<PatchSet.Id, PatchSet> sortedPatchsets =
+        Arrays.stream(patchsets)
+            .collect(
+                ImmutableSortedMap.toImmutableSortedMap(
+                    comparing(PatchSet.Id::get), PatchSet::id, patchset -> patchset));
+    when(changeNotes.getPatchSets()).thenReturn(sortedPatchsets);
+    return changeNotes;
+  }
+
+  private HumanComment createComment(PatchSet.Id patchsetId, String filePath) {
+    return new HumanComment(
+        new Comment.Key(getUniqueUuid(), filePath, patchsetId.get()),
+        Account.id(100),
+        new Timestamp(1234),
+        (short) 1,
+        "Comment text",
+        "serverId",
+        true);
+  }
+
+  private String getUniqueUuid() {
+    return "commentUuid" + uuidCounter++;
+  }
+
+  private Correspondence<HumanComment, String> hasFilePath() {
+    return NullAwareCorrespondence.transforming(comment -> comment.key.filename, "hasFilePath");
+  }
+
+  private PatchList getEmptyDiff() {
+    return new PatchList(
+        dummyObjectId,
+        dummyObjectId,
+        false,
+        ComparisonType.againstOtherPatchSet(),
+        new PatchListEntry[0]);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 9f34377..5cefe74 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -1,29 +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.
- */
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.MoreCollectors.onlyElement;
 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.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -31,48 +33,111 @@
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
 
+  @SuppressWarnings("TruthIncompatibleType")
   @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"));
+  public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
+    /* Comments should not be linked to Gerrit's autogenerated messages */
+    List<CommentInfo> comments = createComments("c1", "00", "c2", "10", "c3", "25");
+    List<ChangeMessage> changeMessages =
+        createChangeMessages("cm1", "00", "cm2", "16", "cm3", "30");
 
-    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"));
+    changeMessages.add(
+        newChangeMessage("ignore", "cmAutoGenByGerrit", "15", ChangeMessagesUtil.TAG_MERGED));
 
-    assertThat(c1.changeMessageId).isNull();
-    assertThat(c2.changeMessageId).isNull();
-    assertThat(c3.changeMessageId).isNull();
+    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages, true);
 
-    ImmutableList<CommentInfo> comments = ImmutableList.of(c1, c2, c3);
-    ImmutableList<ChangeMessage> changeMessages = ImmutableList.of(cm1, cm2, cm3);
+    assertThat(getComment(comments, "c1").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm1").getKey().uuid());
+    /* comment 2 ignored the auto-generated message because it has a Gerrit tag */
+    assertThat(getComment(comments, "c2").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm2").getKey().uuid());
+    assertThat(getComment(comments, "c3").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
 
-    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages);
-
-    assertThat(c1.changeMessageId).isEqualTo(changeMessageKey(cm2));
-    assertThat(c2.changeMessageId).isEqualTo(changeMessageKey(cm2));
-    assertThat(c3.changeMessageId).isEqualTo(changeMessageKey(cm3));
+    // Make sure no comment is linked to the auto-gen message
+    assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
+        .doesNotContain(
+            /* expected: String, actual: ChangeMessage */ getChangeMessage(
+                changeMessages, "cmAutoGenByGerrit"));
   }
 
-  private static CommentInfo getNewCommentInfo(String message, Timestamp ts) {
+  @Test
+  public void commentsLinkedToChangeMessagesAllowLinkingToAutoGenTaggedMessages() {
+    /* Human comments are allowed to be linked to autogenerated messages */
+    List<CommentInfo> comments = createComments("c1", "00", "c2", "10", "c3", "25");
+    List<ChangeMessage> changeMessages =
+        createChangeMessages("cm1", "00", "cm2", "16", "cm3", "30");
+
+    changeMessages.add(
+        newChangeMessage(
+            "cmAutoGen", "cmAutoGen", "15", ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX));
+
+    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages, true);
+
+    assertThat(getComment(comments, "c1").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm1").getKey().uuid());
+    /* comment 2 did not ignore the auto-generated change message */
+    assertThat(getComment(comments, "c2").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cmAutoGen").getKey().uuid());
+    assertThat(getComment(comments, "c3").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
+  }
+
+  /**
+   * Create a list of comments from the specified args args should be passed as consecutive pairs of
+   * messages and timestamps example: (m1, t1, m2, t2, ...)
+   */
+  private static List<CommentInfo> createComments(String... args) {
+    List<CommentInfo> comments = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      String message = args[i];
+      String ts = args[i + 1];
+      comments.add(newCommentInfo(message, ts));
+    }
+    return comments;
+  }
+
+  /**
+   * Create a list of change messages from the specified args args should be passed as consecutive
+   * pairs of messages and timestamps example: (m1, t1, m2, t2, ...). the tag parameter for the
+   * created change messages will be null.
+   */
+  private static List<ChangeMessage> createChangeMessages(String... args) {
+    List<ChangeMessage> changeMessages = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      String key = args[i] + "Key";
+      String message = args[i];
+      String ts = args[i + 1];
+      changeMessages.add(newChangeMessage(key, message, ts, null));
+    }
+    return changeMessages;
+  }
+
+  /** Create a new CommentInfo with a given message and timestamp */
+  private static CommentInfo newCommentInfo(String message, String ts) {
     CommentInfo c = new CommentInfo();
     c.message = message;
-    c.updated = ts;
+    c.updated = Timestamp.valueOf("2000-01-01 00:00:" + ts);
     return c;
   }
 
-  private static ChangeMessage getNewChangeMessage(String id, String message, Timestamp ts) {
+  /** Create a new change message with an id, message, timestamp and tag */
+  private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
-    ChangeMessage cm = new ChangeMessage(key, null, ts, null);
+    ChangeMessage cm =
+        new ChangeMessage(key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null);
     cm.setMessage(message);
+    cm.setTag(tag);
     return cm;
   }
 
-  private static String changeMessageKey(ChangeMessage changeMessage) {
-    return changeMessage.getKey().uuid();
+  /** Return the change message from the list of messages that has specific message text */
+  private static ChangeMessage getChangeMessage(List<ChangeMessage> messages, String messageText) {
+    return messages.stream().filter(m -> m.getMessage().equals(messageText)).collect(onlyElement());
+  }
+
+  /** Return the comment from the list of comments that has specific message text */
+  private CommentInfo getComment(List<CommentInfo> comments, String messageText) {
+    return comments.stream().filter(c -> c.message.equals(messageText)).collect(onlyElement());
   }
 }
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 9d7afbc..871c871 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -19,7 +19,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index d8af0e5..cf5e8fe 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -17,11 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import java.time.Instant;
@@ -72,12 +72,12 @@
   private static LabelType makeLabel(String labelName) {
     List<LabelValue> values = new ArrayList<>();
     // The label text is irrelevant here, only the numerical value is used
-    values.add(new LabelValue((short) -2, "-2"));
-    values.add(new LabelValue((short) -1, "-1"));
-    values.add(new LabelValue((short) 0, "No vote."));
-    values.add(new LabelValue((short) 1, "+1"));
-    values.add(new LabelValue((short) 2, "+2"));
-    return new LabelType(labelName, values);
+    values.add(LabelValue.create((short) -2, "-2"));
+    values.add(LabelValue.create((short) -1, "-1"));
+    values.add(LabelValue.create((short) 0, "No vote."));
+    values.add(LabelValue.create((short) 1, "+1"));
+    values.add(LabelValue.create((short) 2, "+2"));
+    return LabelType.create(labelName, values);
   }
 
   private static PatchSetApproval makeApproval(LabelId labelId, Account.Id accountId, int value) {
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index b65f4d2..d6c5b5a 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -23,11 +23,11 @@
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
@@ -44,12 +44,12 @@
 
 public class AllProjectsCreatorTest {
   private static final LabelType TEST_LABEL =
-      new LabelType(
+      LabelType.create(
           "Test-Label",
           ImmutableList.of(
-              new LabelValue((short) 2, "Two"),
-              new LabelValue((short) 0, "Zero"),
-              new LabelValue((short) 1, "One")));
+              LabelValue.create((short) 2, "Two"),
+              LabelValue.create((short) 0, "Zero"),
+              LabelValue.create((short) 1, "One")));
 
   private static final String TEST_LABEL_STRING =
       String.join(
@@ -88,11 +88,11 @@
     expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
 
     GroupReference adminsGroup = createGroupReference("Administrators");
-    GroupReference batchUsersGroup = createGroupReference("Non-Interactive Users");
+    GroupReference batchUsersGroup = createGroupReference("Service Users");
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
-            .batchUsersGroup(batchUsersGroup)
+            .serviceUsersGroup(batchUsersGroup)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
@@ -102,7 +102,7 @@
 
   private GroupReference createGroupReference(String name) {
     AccountGroup.UUID groupUuid = GroupUuid.make(name, serverUser);
-    return new GroupReference(groupUuid, name);
+    return GroupReference.create(groupUuid, name);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index beb0e32..646f0cd 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -94,7 +94,7 @@
 
       args =
           new NoteDbSchemaVersion.Arguments(
-              repoManager, allProjectsName, allUsersName, null, null, null);
+              repoManager, allProjectsName, allUsersName, null, null, null, null);
       NoteDbSchemaVersionManager versionManager =
           new NoteDbSchemaVersionManager(allProjectsName, repoManager);
       updater =
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index c92a8e0..01a44f3 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -18,12 +18,15 @@
 import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
 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.db.GroupNameNotes;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Inject;
@@ -37,12 +40,11 @@
 
 public class SchemaCreatorImplTest {
   @Inject private AllProjectsName allProjects;
-
   @Inject private GitRepositoryManager repoManager;
-
   @Inject private SchemaCreator schemaCreator;
-
   @Inject private ProjectConfig.Factory projectConfigFactory;
+  @Inject private GitRepositoryManager repositoryManager;
+  @Inject private AllUsersName allUsersName;
 
   @Before
   public void setUp() throws Exception {
@@ -78,6 +80,12 @@
     assertValueRange(codeReview, -2, -1, 0, 1, 2);
   }
 
+  @Test
+  public void groupIsCreatedWhenSchemaIsCreated() throws Exception {
+    assertThat(hasGroup("Service Users")).isTrue();
+    assertThat(hasGroup("Non-Interactive Users")).isFalse();
+  }
+
   private void assertValueRange(LabelType label, Integer... range) {
     List<Integer> rangeList = Arrays.asList(range);
     assertThat(rangeList).isNotEmpty();
@@ -93,4 +101,11 @@
       assertThat(v.getText()).isNotEmpty();
     }
   }
+
+  private boolean hasGroup(String name) throws Exception {
+    try (Repository repo = repositoryManager.openRepository(allUsersName)) {
+      List<GroupReference> nameNotes = GroupNameNotes.loadAllGroups(repo);
+      return nameNotes.stream().anyMatch(g -> g.getName().equals(name));
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/server/submit/BUILD b/javatests/com/google/gerrit/server/submit/BUILD
new file mode 100644
index 0000000..7425bc8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/BUILD
@@ -0,0 +1,21 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "submit_tests",
+    size = "small",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
+        "//lib/mockito",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "//lib/truth:truth-proto-extension",
+        "@jgit//org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
new file mode 100644
index 0000000..313e697
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
@@ -0,0 +1,225 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+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.submit.MergeOpRepoManager.OpenRepo;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
+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;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class SubmoduleCommitsTest {
+
+  private static final String MASTER = "refs/heads/master";
+  private static final Project.NameKey superProject = Project.nameKey("superproject");
+  private static final Project.NameKey subProject = Project.nameKey("subproject");
+
+  private static final PersonIdent ident = new PersonIdent("submodule-test", "a@b.com");
+
+  private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+  private MergeOpRepoManager mergeOpRepoManager;
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+  @Mock private ProjectCache mockProjectCache;
+  @Mock private ProjectState mockProjectState;
+
+  @Test
+  public void createGitlinksCommit_subprojectMoved() throws Exception {
+    createRepo(subProject, MASTER);
+    createRepo(superProject, MASTER);
+
+    when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+    mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+    ObjectId subprojectCommit = getTip(subProject, MASTER);
+    RevCommit superprojectTip =
+        directUpdateSubmodule(superProject, MASTER, Project.nameKey("dir-x"), subprojectCommit);
+    assertThat(readGitLink(superProject, superprojectTip, "dir-x")).isEqualTo(subprojectCommit);
+
+    RevCommit newSubprojectCommit = addCommit(subProject, MASTER);
+
+    BranchNameKey superBranch = BranchNameKey.create(superProject, MASTER);
+    BranchNameKey subBranch = BranchNameKey.create(subProject, MASTER);
+    SubmoduleSubscription ss = new SubmoduleSubscription(superBranch, subBranch, "dir-x");
+    SubmoduleCommits helper = new SubmoduleCommits(mergeOpRepoManager, ident, new Config());
+    Optional<CodeReviewCommit> newGitLinksCommit =
+        helper.composeGitlinksCommit(
+            BranchNameKey.create(superProject, MASTER), ImmutableList.of(ss));
+
+    assertThat(newGitLinksCommit).isPresent();
+    assertThat(newGitLinksCommit.get().getParent(0)).isEqualTo(superprojectTip);
+    assertThat(readGitLink(superProject, newGitLinksCommit.get(), "dir-x"))
+        .isEqualTo(newSubprojectCommit);
+  }
+
+  @Test
+  public void amendGitlinksCommit_subprojectMoved() throws Exception {
+    createRepo(subProject, MASTER);
+    createRepo(superProject, MASTER);
+
+    when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+    mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+    ObjectId subprojectCommit = getTip(subProject, MASTER);
+    CodeReviewCommit superprojectTip =
+        directUpdateSubmodule(superProject, MASTER, Project.nameKey("dir-x"), subprojectCommit);
+    assertThat(readGitLink(superProject, superprojectTip, "dir-x")).isEqualTo(subprojectCommit);
+
+    RevCommit newSubprojectCommit = addCommit(subProject, MASTER);
+
+    BranchNameKey superBranch = BranchNameKey.create(superProject, MASTER);
+    BranchNameKey subBranch = BranchNameKey.create(subProject, MASTER);
+    SubmoduleSubscription ss = new SubmoduleSubscription(superBranch, subBranch, "dir-x");
+    SubmoduleCommits helper = new SubmoduleCommits(mergeOpRepoManager, ident, new Config());
+    CodeReviewCommit amendedCommit =
+        helper.amendGitlinksCommit(
+            BranchNameKey.create(superProject, MASTER), superprojectTip, ImmutableList.of(ss));
+
+    assertThat(amendedCommit.getParent(0)).isEqualTo(superprojectTip.getParent(0));
+    assertThat(readGitLink(superProject, amendedCommit, "dir-x")).isEqualTo(newSubprojectCommit);
+  }
+
+  /** Create repo with a commit on refName */
+  private void createRepo(Project.NameKey projectKey, String refName) throws Exception {
+    Repository repo = repoManager.createRepository(projectKey);
+    try (TestRepository<Repository> git = new TestRepository<>(repo)) {
+      RevCommit newCommit = git.commit().message("Initial commit for " + projectKey).create();
+      git.update(refName, newCommit);
+    }
+  }
+
+  private ObjectId getTip(Project.NameKey projectKey, String refName)
+      throws RepositoryNotFoundException, IOException {
+    return repoManager.openRepository(projectKey).exactRef(refName).getObjectId();
+  }
+
+  private RevCommit addCommit(Project.NameKey projectKey, String refName) throws Exception {
+    try (Repository serverRepo = repoManager.openRepository(projectKey);
+        RevWalk rw = new RevWalk(serverRepo);
+        TestRepository<Repository> git = new TestRepository<>(serverRepo, rw)) {
+      Ref ref = serverRepo.exactRef(refName);
+      assertWithMessage(refName).that(ref).isNotNull();
+
+      RevCommit originalTip = rw.parseCommit(ref.getObjectId());
+      RevCommit newTip =
+          git.commit().parent(originalTip).message("Added commit to " + projectKey).create();
+      git.update(refName, newTip);
+      return newTip;
+    }
+  }
+
+  private CodeReviewCommit directUpdateSubmodule(
+      Project.NameKey project, String refName, Project.NameKey path, AnyObjectId id)
+      throws Exception {
+    OpenRepo or = mergeOpRepoManager.getRepo(project);
+    Repository serverRepo = or.repo;
+    ObjectInserter ins = or.ins;
+    CodeReviewRevWalk rw = or.rw;
+    Ref ref = serverRepo.exactRef(refName);
+    assertWithMessage(refName).that(ref).isNotNull();
+    ObjectId oldCommitId = ref.getObjectId();
+
+    DirCache dc = DirCache.newInCore();
+    DirCacheBuilder b = dc.builder();
+    b.addTree(new byte[0], DirCacheEntry.STAGE_0, rw.getObjectReader(), rw.parseTree(oldCommitId));
+    b.finish();
+    DirCacheEditor e = dc.editor();
+    e.add(
+        new PathEdit(path.get()) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.GITLINK);
+            ent.setObjectId(id);
+          }
+        });
+    e.finish();
+
+    CommitBuilder cb = new CommitBuilder();
+    cb.addParentId(oldCommitId);
+    cb.setTreeId(dc.writeTree(ins));
+
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    cb.setMessage("Direct update submodule " + path);
+    ObjectId newCommitId = ins.insert(cb);
+    ins.flush();
+
+    RefUpdate ru = serverRepo.updateRef(refName);
+    ru.setExpectedOldObjectId(oldCommitId);
+    ru.setNewObjectId(newCommitId);
+    assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+    return rw.parseCommit(newCommitId);
+  }
+
+  private ObjectId readGitLink(Project.NameKey projectKey, RevCommit commit, String path)
+      throws IOException, NoSuchProjectException {
+    // SubmoduleCommitHelper used mergeOpRepoManager to create the commit
+    // Read the repo from mergeOpRepoManager to get also the RevWalk that created the commit
+    return readGitLinkInCommit(mergeOpRepoManager.getRepo(projectKey).rw, commit, path);
+  }
+
+  private ObjectId readGitLinkInCommit(RevWalk rw, RevCommit commit, String path)
+      throws IOException {
+    DirCache dc = DirCache.newInCore();
+    DirCacheBuilder b = dc.builder();
+    b.addTree(
+        new byte[0], // no prefix path
+        DirCacheEntry.STAGE_0, // standard stage
+        rw.getObjectReader(),
+        commit.getTree());
+    b.finish();
+    DirCacheEntry entry = dc.getEntry(path);
+    assertThat(entry.getFileMode()).isEqualTo(FileMode.GITLINK);
+    return entry.getObjectId();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
new file mode 100644
index 0000000..fb995fd
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
@@ -0,0 +1,213 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.entities.SubscribeSection;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.submit.SubscriptionGraph.DefaultFactory;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+public class SubscriptionGraphTest {
+  private static final String TEST_PATH = "test/path";
+  private static final Project.NameKey SUPER_PROJECT = Project.nameKey("Superproject");
+  private static final Project.NameKey SUB_PROJECT = Project.nameKey("Subproject");
+  private static final BranchNameKey SUPER_BRANCH =
+      BranchNameKey.create(SUPER_PROJECT, "refs/heads/one");
+  private static final BranchNameKey SUB_BRANCH =
+      BranchNameKey.create(SUB_PROJECT, "refs/heads/one");
+  private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+  private MergeOpRepoManager mergeOpRepoManager;
+
+  @Mock GitModules.Factory mockGitModulesFactory = mock(GitModules.Factory.class);
+  @Mock ProjectCache mockProjectCache = mock(ProjectCache.class);
+  @Mock ProjectState mockProjectState = mock(ProjectState.class);
+
+  @Before
+  public void setUp() throws Exception {
+    when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+    mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+    GitModules emptyMockGitModules = mock(GitModules.class);
+    when(emptyMockGitModules.subscribedTo(any())).thenReturn(ImmutableSet.of());
+    when(mockGitModulesFactory.create(any(), any())).thenReturn(emptyMockGitModules);
+
+    TestRepository<Repository> superProject = createRepo(SUPER_PROJECT);
+    TestRepository<Repository> submoduleProject = createRepo(SUB_PROJECT);
+
+    // Make sure that SUPER_BRANCH and SUB_BRANCH can be subscribed.
+    allowSubscription(SUPER_BRANCH);
+    allowSubscription(SUB_BRANCH);
+
+    setSubscription(SUB_BRANCH, ImmutableList.of(SUPER_BRANCH));
+    setSubscription(SUPER_BRANCH, ImmutableList.of());
+    createBranch(
+        superProject, SUPER_BRANCH, superProject.commit().message("Initial commit").create());
+    createBranch(
+        submoduleProject, SUB_BRANCH, submoduleProject.commit().message("Initial commit").create());
+  }
+
+  @Test
+  public void oneSuperprojectOneSubmodule() throws Exception {
+    SubscriptionGraph.Factory factory = new DefaultFactory(mockGitModulesFactory, mockProjectCache);
+    SubscriptionGraph subscriptionGraph =
+        factory.compute(ImmutableSet.of(SUB_BRANCH), mergeOpRepoManager);
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects()).containsExactly(SUPER_PROJECT);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(SUPER_PROJECT))
+        .containsExactly(SUPER_BRANCH);
+    assertThat(subscriptionGraph.getSubscriptions(SUPER_BRANCH))
+        .containsExactly(new SubmoduleSubscription(SUPER_BRANCH, SUB_BRANCH, TEST_PATH));
+    assertThat(subscriptionGraph.hasSuperproject(SUB_BRANCH)).isTrue();
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(SUB_BRANCH, SUPER_BRANCH)
+        .inOrder();
+  }
+
+  @Test
+  public void circularSubscription() throws Exception {
+    SubscriptionGraph.Factory factory = new DefaultFactory(mockGitModulesFactory, mockProjectCache);
+    setSubscription(SUPER_BRANCH, ImmutableList.of(SUB_BRANCH));
+    SubmoduleConflictException e =
+        assertThrows(
+            SubmoduleConflictException.class,
+            () -> factory.compute(ImmutableSet.of(SUB_BRANCH), mergeOpRepoManager));
+
+    String expectedErrorMessage =
+        "Subproject,refs/heads/one->Superproject,refs/heads/one->Subproject,refs/heads/one";
+    assertThat(e).hasMessageThat().contains(expectedErrorMessage);
+  }
+
+  @Test
+  public void multipleSuperprojectsToMultipleSubmodules() throws Exception {
+    // Create superprojects and subprojects.
+    Project.NameKey superProject1 = Project.nameKey("superproject1");
+    Project.NameKey superProject2 = Project.nameKey("superproject2");
+    Project.NameKey subProject1 = Project.nameKey("subproject1");
+    Project.NameKey subProject2 = Project.nameKey("subproject2");
+    TestRepository<Repository> superProjectRepo1 = createRepo(superProject1);
+    TestRepository<Repository> superProjectRepo2 = createRepo(superProject2);
+    TestRepository<Repository> submoduleRepo1 = createRepo(subProject1);
+    TestRepository<Repository> submoduleRepo2 = createRepo(subProject2);
+
+    // Initialize super branches.
+    BranchNameKey superBranch1 = BranchNameKey.create(superProject1, "refs/heads/one");
+    BranchNameKey superBranch2 = BranchNameKey.create(superProject2, "refs/heads/one");
+    createBranch(
+        superProjectRepo1,
+        superBranch1,
+        superProjectRepo1.commit().message("Initial commit").create());
+    createBranch(
+        superProjectRepo2,
+        superBranch2,
+        superProjectRepo2.commit().message("Initial commit").create());
+
+    // Initialize sub branches.
+    BranchNameKey submoduleBranch1 = BranchNameKey.create(subProject1, "refs/heads/one");
+    BranchNameKey submoduleBranch2 = BranchNameKey.create(subProject1, "refs/heads/two");
+    BranchNameKey submoduleBranch3 = BranchNameKey.create(subProject2, "refs/heads/one");
+    createBranch(
+        submoduleRepo1, submoduleBranch1, submoduleRepo1.commit().message("Commit1").create());
+    createBranch(
+        submoduleRepo1, submoduleBranch2, submoduleRepo1.commit().message("Commit2").create());
+    createBranch(
+        submoduleRepo2, submoduleBranch3, submoduleRepo2.commit().message("Commit1").create());
+
+    allowSubscription(submoduleBranch1);
+    allowSubscription(submoduleBranch2);
+    allowSubscription(submoduleBranch3);
+
+    // Initialize subscriptions.
+    setSubscription(submoduleBranch1, ImmutableList.of(superBranch1, superBranch2));
+    setSubscription(submoduleBranch2, ImmutableList.of(superBranch1));
+    setSubscription(submoduleBranch3, ImmutableList.of(superBranch1, superBranch2));
+
+    SubscriptionGraph.Factory factory = new DefaultFactory(mockGitModulesFactory, mockProjectCache);
+    SubscriptionGraph subscriptionGraph =
+        factory.compute(ImmutableSet.of(submoduleBranch1, submoduleBranch2), mergeOpRepoManager);
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects())
+        .containsExactly(superProject1, superProject2);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject1))
+        .containsExactly(superBranch1);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject2))
+        .containsExactly(superBranch2);
+
+    assertThat(subscriptionGraph.getSubscriptions(superBranch1))
+        .containsExactly(
+            new SubmoduleSubscription(superBranch1, submoduleBranch1, TEST_PATH),
+            new SubmoduleSubscription(superBranch1, submoduleBranch2, TEST_PATH));
+    assertThat(subscriptionGraph.getSubscriptions(superBranch2))
+        .containsExactly(new SubmoduleSubscription(superBranch2, submoduleBranch1, TEST_PATH));
+
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch1)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch2)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch3)).isFalse();
+
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(submoduleBranch2, submoduleBranch1, superBranch2, superBranch1)
+        .inOrder();
+  }
+
+  private TestRepository<Repository> createRepo(Project.NameKey project) throws Exception {
+    Repository repo = repoManager.createRepository(project);
+    return new TestRepository<>(repo);
+  }
+
+  private void createBranch(TestRepository<Repository> repo, BranchNameKey branch, RevCommit commit)
+      throws Exception {
+    repo.update(branch.branch(), commit);
+  }
+
+  private void allowSubscription(BranchNameKey branch) {
+    SubscribeSection.Builder s = SubscribeSection.builder(branch.project());
+    s.addMultiMatchRefSpec("refs/heads/*:refs/heads/*");
+    when(mockProjectState.getSubscribeSections(branch)).thenReturn(ImmutableSet.of(s.build()));
+  }
+
+  private void setSubscription(
+      BranchNameKey submoduleBranch, List<BranchNameKey> superprojectBranches) {
+    List<SubmoduleSubscription> subscriptions =
+        superprojectBranches.stream()
+            .map(
+                (targetBranch) ->
+                    new SubmoduleSubscription(targetBranch, submoduleBranch, TEST_PATH))
+            .collect(Collectors.toList());
+    GitModules mockGitModules = mock(GitModules.class);
+    when(mockGitModules.subscribedTo(submoduleBranch)).thenReturn(subscriptions);
+    when(mockGitModulesFactory.create(submoduleBranch, mergeOpRepoManager))
+        .thenReturn(mockGitModules);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 083493d..287a7fe 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -22,12 +22,12 @@
 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;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeInserter;
diff --git a/lib/BUILD b/lib/BUILD
index d3ef4b9..0110047 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -160,7 +160,7 @@
     name = "args4j",
     data = ["//lib:LICENSE-args4j"],
     visibility = ["//visibility:public"],
-    exports = ["@args4j-intern//jar"],
+    exports = ["@args4j//jar"],
 )
 
 java_library(
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index b60a101..1da7f50 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -18,12 +18,22 @@
     ],
 )
 
+java_plugin(
+    name = "auto-oneof-plugin",
+    processor_class = "com.google.auto.value.processor.AutoOneOfProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
+    ],
+)
+
 java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
     exported_plugins = [
         ":auto-annotation-plugin",
         ":auto-value-plugin",
+        ":auto-oneof-plugin",
     ],
     visibility = ["//visibility:public"],
     exports = ["@auto-value//jar"],
@@ -35,6 +45,7 @@
     exported_plugins = [
         ":auto-annotation-plugin",
         ":auto-value-plugin",
+        ":auto-oneof-plugin",
     ],
     visibility = ["//visibility:public"],
     exports = ["@auto-value-annotations//jar"],
diff --git a/lib/highlightjs/highlight.min.js b/lib/highlightjs/highlight.min.js
index 458cb0c..ad4d1fe 100644
--- a/lib/highlightjs/highlight.min.js
+++ b/lib/highlightjs/highlight.min.js
@@ -1,7 +1,7 @@
 /*
-  Highlight.js 10.6.0 (d24895f4)
+  Highlight.js 10.7.2 (00233d63)
   License: BSD-3-Clause
-  Copyright (c) 2006-2020, Ivan Sagalaev
+  Copyright (c) 2006-2021, Ivan Sagalaev
 */
 var hljs=function(){"use strict";function e(t){
 return t instanceof Map?t.clear=t.delete=t.set=()=>{
@@ -9,16 +9,17 @@
 throw Error("set is read-only")
 }),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{var i=t[n]
 ;"object"!=typeof i||Object.isFrozen(i)||e(i)})),t}var t=e,n=e;t.default=n
-;class i{constructor(e){void 0===e.data&&(e.data={}),this.data=e.data}
-ignoreMatch(){this.ignore=!0}}function r(e){
+;class i{constructor(e){
+void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1}
+ignoreMatch(){this.isMatchIgnored=!0}}function s(e){
 return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;")
-}function s(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t]
-;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const a=e=>!!e.kind
+}function a(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t]
+;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const r=e=>!!e.kind
 ;class l{constructor(e,t){
 this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){
-this.buffer+=r(e)}openNode(e){if(!a(e))return;let t=e.kind
+this.buffer+=s(e)}openNode(e){if(!r(e))return;let t=e.kind
 ;e.sublanguage||(t=`${this.classPrefix}${t}`),this.span(t)}closeNode(e){
-a(e)&&(this.buffer+="</span>")}value(){return this.buffer}span(e){
+r(e)&&(this.buffer+="</span>")}value(){return this.buffer}span(e){
 this.buffer+=`<span class="${e}">`}}class o{constructor(){this.rootNode={
 children:[]},this.stack=[this.rootNode]}get top(){
 return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){
@@ -36,21 +37,21 @@
 ;n.kind=t,n.sublanguage=!0,this.add(n)}toHTML(){
 return new l(this,this.options).value()}finalize(){return!0}}function g(e){
 return e?"string"==typeof e?e:e.source:null}
-const u=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,d="[a-zA-Z]\\w*",h="[a-zA-Z_]\\w*",f="\\b\\d+(\\.\\d+)?",p="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",m="\\b(0b[01]+)",b={
+const u=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,h="[a-zA-Z]\\w*",d="[a-zA-Z_]\\w*",f="\\b\\d+(\\.\\d+)?",p="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",m="\\b(0b[01]+)",b={
 begin:"\\\\[\\s\\S]",relevance:0},E={className:"string",begin:"'",end:"'",
 illegal:"\\n",contains:[b]},x={className:"string",begin:'"',end:'"',
 illegal:"\\n",contains:[b]},v={
 begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/
-},w=(e,t,n={})=>{const i=s({className:"comment",begin:e,end:t,contains:[]},n)
+},w=(e,t,n={})=>{const i=a({className:"comment",begin:e,end:t,contains:[]},n)
 ;return i.contains.push(v),i.contains.push({className:"doctag",
 begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),i
 },y=w("//","$"),N=w("/\\*","\\*/"),R=w("#","$");var _=Object.freeze({
-__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:d,UNDERSCORE_IDENT_RE:h,
+__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:h,UNDERSCORE_IDENT_RE:d,
 NUMBER_RE:f,C_NUMBER_RE:p,BINARY_NUMBER_RE:m,
 RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",
 SHEBANG:(e={})=>{const t=/^#![ ]*\//
 ;return e.binary&&(e.begin=((...e)=>e.map((e=>g(e))).join(""))(t,/.*\b/,e.binary,/\b.*/)),
-s({className:"meta",begin:t,end:/$/,relevance:0,"on:begin":(e,t)=>{
+a({className:"meta",begin:t,end:/$/,relevance:0,"on:begin":(e,t)=>{
 0!==e.index&&t.ignoreMatch()}},e)},BACKSLASH_ESCAPE:b,APOS_STRING_MODE:E,
 QUOTE_STRING_MODE:x,PHRASAL_WORDS_MODE:v,COMMENT:w,C_LINE_COMMENT_MODE:y,
 C_BLOCK_COMMENT_MODE:N,HASH_COMMENT_MODE:R,NUMBER_MODE:{className:"number",
@@ -60,27 +61,27 @@
 begin:f+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",
 relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",
 begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b,{begin:/\[/,end:/\]/,
-relevance:0,contains:[b]}]}]},TITLE_MODE:{className:"title",begin:d,relevance:0
-},UNDERSCORE_TITLE_MODE:{className:"title",begin:h,relevance:0},METHOD_GUARD:{
+relevance:0,contains:[b]}]}]},TITLE_MODE:{className:"title",begin:h,relevance:0
+},UNDERSCORE_TITLE_MODE:{className:"title",begin:d,relevance:0},METHOD_GUARD:{
 begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:e=>Object.assign(e,{
 "on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{
 t.data._beginMatch!==e[1]&&t.ignoreMatch()}})});function k(e,t){
-"."===e.input[e.index-1]&&t.ignoreMatch()}function O(e,t){
+"."===e.input[e.index-1]&&t.ignoreMatch()}function M(e,t){
 t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",
 e.__beforeBegin=k,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,
-void 0===e.relevance&&(e.relevance=0))}function M(e,t){
+void 0===e.relevance&&(e.relevance=0))}function O(e,t){
 Array.isArray(e.illegal)&&(e.illegal=((...e)=>"("+e.map((e=>g(e))).join("|")+")")(...e.illegal))
 }function A(e,t){if(e.match){
 if(e.begin||e.end)throw Error("begin & end are not supported with match")
 ;e.begin=e.match,delete e.match}}function L(e,t){
 void 0===e.relevance&&(e.relevance=1)}
-const j=["of","and","for","in","not","or","if","then","parent","list","value"]
-;function B(e,t,n="keyword"){const i={}
-;return"string"==typeof e?r(n,e.split(" ")):Array.isArray(e)?r(n,e):Object.keys(e).forEach((n=>{
-Object.assign(i,B(e[n],t,n))})),i;function r(e,n){
+const I=["of","and","for","in","not","or","if","then","parent","list","value"]
+;function j(e,t,n="keyword"){const i={}
+;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{
+Object.assign(i,j(e[n],t,n))})),i;function s(e,n){
 t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|")
-;i[n[0]]=[e,I(n[0],n[1])]}))}}function I(e,t){
-return t?Number(t):(e=>j.includes(e.toLowerCase()))(e)?0:1}
+;i[n[0]]=[e,B(n[0],n[1])]}))}}function B(e,t){
+return t?Number(t):(e=>I.includes(e.toLowerCase()))(e)?0:1}
 function T(e,{plugins:t}){function n(t,n){
 return RegExp(g(t),"m"+(e.case_insensitive?"i":"")+(n?"g":""))}class i{
 constructor(){
@@ -90,15 +91,15 @@
 this.matchAt+=(e=>RegExp(e.toString()+"|").exec("").length-1)(e)+1}compile(){
 0===this.regexes.length&&(this.exec=()=>null)
 ;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(((e,t="|")=>{let n=0
-;return e.map((e=>{n+=1;const t=n;let i=g(e),r="";for(;i.length>0;){
-const e=u.exec(i);if(!e){r+=i;break}
-r+=i.substring(0,e.index),i=i.substring(e.index+e[0].length),
-"\\"===e[0][0]&&e[1]?r+="\\"+(Number(e[1])+t):(r+=e[0],"("===e[0]&&n++)}return r
+;return e.map((e=>{n+=1;const t=n;let i=g(e),s="";for(;i.length>0;){
+const e=u.exec(i);if(!e){s+=i;break}
+s+=i.substring(0,e.index),i=i.substring(e.index+e[0].length),
+"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0],"("===e[0]&&n++)}return s
 })).map((e=>`(${e})`)).join(t)})(e),!0),this.lastIndex=0}exec(e){
 this.matcherRe.lastIndex=this.lastIndex;const t=this.matcherRe.exec(e)
 ;if(!t)return null
 ;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n]
-;return t.splice(0,n),Object.assign(t,i)}}class r{constructor(){
+;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){
 this.rules=[],this.multiRegexes=[],
 this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){
 if(this.multiRegexes[e])return this.multiRegexes[e];const t=new i
@@ -114,26 +115,26 @@
 this.regexIndex===this.count&&this.considerAll()),n}}
 if(e.compilerExtensions||(e.compilerExtensions=[]),
 e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language.  See documentation.")
-;return e.classNameAliases=s(e.classNameAliases||{}),function t(i,a){const l=i
-;if(i.compiled)return l
-;[A].forEach((e=>e(i,a))),e.compilerExtensions.forEach((e=>e(i,a))),
-i.__beforeBegin=null,[O,M,L].forEach((e=>e(i,a))),i.compiled=!0;let o=null
+;return e.classNameAliases=a(e.classNameAliases||{}),function t(i,r){const l=i
+;if(i.isCompiled)return l
+;[A].forEach((e=>e(i,r))),e.compilerExtensions.forEach((e=>e(i,r))),
+i.__beforeBegin=null,[M,O,L].forEach((e=>e(i,r))),i.isCompiled=!0;let o=null
 ;if("object"==typeof i.keywords&&(o=i.keywords.$pattern,
 delete i.keywords.$pattern),
-i.keywords&&(i.keywords=B(i.keywords,e.case_insensitive)),
+i.keywords&&(i.keywords=j(i.keywords,e.case_insensitive)),
 i.lexemes&&o)throw Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ")
 ;return o=o||i.lexemes||/\w+/,
-l.keywordPatternRe=n(o,!0),a&&(i.begin||(i.begin=/\B|\b/),
+l.keywordPatternRe=n(o,!0),r&&(i.begin||(i.begin=/\B|\b/),
 l.beginRe=n(i.begin),i.endSameAsBegin&&(i.end=i.begin),
 i.end||i.endsWithParent||(i.end=/\B|\b/),
 i.end&&(l.endRe=n(i.end)),l.terminatorEnd=g(i.end)||"",
-i.endsWithParent&&a.terminatorEnd&&(l.terminatorEnd+=(i.end?"|":"")+a.terminatorEnd)),
+i.endsWithParent&&r.terminatorEnd&&(l.terminatorEnd+=(i.end?"|":"")+r.terminatorEnd)),
 i.illegal&&(l.illegalRe=n(i.illegal)),
-i.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>s(e,{
-variants:null},t)))),e.cachedVariants?e.cachedVariants:S(e)?s(e,{
-starts:e.starts?s(e.starts):null
-}):Object.isFrozen(e)?s(e):e))("self"===e?i:e)))),i.contains.forEach((e=>{t(e,l)
-})),i.starts&&t(i.starts,a),l.matcher=(e=>{const t=new r
+i.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>a(e,{
+variants:null},t)))),e.cachedVariants?e.cachedVariants:S(e)?a(e,{
+starts:e.starts?a(e.starts):null
+}):Object.isFrozen(e)?a(e):e))("self"===e?i:e)))),i.contains.forEach((e=>{t(e,l)
+})),i.starts&&t(i.starts,r),l.matcher=(e=>{const t=new s
 ;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin"
 }))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end"
 }),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(l),l}(e)}function S(e){
@@ -142,7 +143,7 @@
 unknownLanguage:!1}),computed:{className(){
 return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){
 if(!this.autoDetect&&!e.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),
-this.unknownLanguage=!0,r(this.code);let t={}
+this.unknownLanguage=!0,s(this.code);let t={}
 ;return this.autoDetect?(t=e.highlightAuto(this.code),
 this.detectedLanguage=t.language):(t=e.highlight(this.language,this.code,this.ignoreIllegals),
 this.detectedLanguage=this.language),t.value},autoDetect(){
@@ -151,96 +152,102 @@
 class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{
 Component:t,VuePlugin:{install(e){e.component("highlightjs",t)}}}}const D={
 "after:highlightElement":({el:e,result:t,text:n})=>{const i=H(e)
-;if(!i.length)return;const s=document.createElement("div")
-;s.innerHTML=t.value,t.value=((e,t,n)=>{let i=0,s="";const a=[];function l(){
+;if(!i.length)return;const a=document.createElement("div")
+;a.innerHTML=t.value,t.value=((e,t,n)=>{let i=0,a="";const r=[];function l(){
 return e.length&&t.length?e[0].offset!==t[0].offset?e[0].offset<t[0].offset?e:t:"start"===t[0].event?e:t:e.length?e:t
-}function o(e){s+="<"+C(e)+[].map.call(e.attributes,(function(e){
-return" "+e.nodeName+'="'+r(e.value)+'"'})).join("")+">"}function c(e){
-s+="</"+C(e)+">"}function g(e){("start"===e.event?o:c)(e.node)}
+}function o(e){a+="<"+C(e)+[].map.call(e.attributes,(function(e){
+return" "+e.nodeName+'="'+s(e.value)+'"'})).join("")+">"}function c(e){
+a+="</"+C(e)+">"}function g(e){("start"===e.event?o:c)(e.node)}
 for(;e.length||t.length;){let t=l()
-;if(s+=r(n.substring(i,t[0].offset)),i=t[0].offset,t===e){a.reverse().forEach(c)
+;if(a+=s(n.substring(i,t[0].offset)),i=t[0].offset,t===e){r.reverse().forEach(c)
 ;do{g(t.splice(0,1)[0]),t=l()}while(t===e&&t.length&&t[0].offset===i)
-;a.reverse().forEach(o)
-}else"start"===t[0].event?a.push(t[0].node):a.pop(),g(t.splice(0,1)[0])}
-return s+r(n.substr(i))})(i,H(s),n)}};function C(e){
+;r.reverse().forEach(o)
+}else"start"===t[0].event?r.push(t[0].node):r.pop(),g(t.splice(0,1)[0])}
+return a+s(n.substr(i))})(i,H(a),n)}};function C(e){
 return e.nodeName.toLowerCase()}function H(e){const t=[];return function e(n,i){
-for(let r=n.firstChild;r;r=r.nextSibling)3===r.nodeType?i+=r.nodeValue.length:1===r.nodeType&&(t.push({
-event:"start",offset:i,node:r}),i=e(r,i),C(r).match(/br|hr|img|input/)||t.push({
-event:"stop",offset:i,node:r}));return i}(e,0),t}const $=e=>{console.error(e)
-},U=(e,...t)=>{console.log("WARN: "+e,...t)},z=(e,t)=>{
-console.log(`Deprecated as of ${e}. ${t}`)},K=r,G=s,V=Symbol("nomatch")
-;return(e=>{const n=Object.create(null),r=Object.create(null),s=[];let a=!0
+for(let s=n.firstChild;s;s=s.nextSibling)3===s.nodeType?i+=s.nodeValue.length:1===s.nodeType&&(t.push({
+event:"start",offset:i,node:s}),i=e(s,i),C(s).match(/br|hr|img|input/)||t.push({
+event:"stop",offset:i,node:s}));return i}(e,0),t}const $={},U=e=>{
+console.error(e)},z=(e,...t)=>{console.log("WARN: "+e,...t)},K=(e,t)=>{
+$[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),$[`${e}/${t}`]=!0)
+},G=s,V=a,W=Symbol("nomatch");return(e=>{
+const n=Object.create(null),s=Object.create(null),a=[];let r=!0
 ;const l=/(^(<[^>]+>|\t|)+|\n)/gm,o="Could not find the language '{}', did you forget to load/include a language module?",g={
 disableAutodetect:!0,name:"Plain text",contains:[]};let u={
 noHighlightRe:/^(no-?highlight)$/i,
 languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",
-tabReplace:null,useBR:!1,languages:null,__emitter:c};function d(e){
-return u.noHighlightRe.test(e)}function h(e,t,n,i){const r={code:t,language:e}
-;M("before:highlight",r);const s=r.result?r.result:f(r.language,r.code,n,i)
-;return s.code=r.code,M("after:highlight",s),s}function f(e,t,r,l){const c=t
-;function g(e,t){const n=w.case_insensitive?t[0].toLowerCase():t[0]
+tabReplace:null,useBR:!1,languages:null,__emitter:c};function h(e){
+return u.noHighlightRe.test(e)}function d(e,t,n,i){let s="",a=""
+;"object"==typeof t?(s=e,
+n=t.ignoreIllegals,a=t.language,i=void 0):(K("10.7.0","highlight(lang, code, ...args) has been deprecated."),
+K("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),
+a=e,s=t);const r={code:s,language:a};M("before:highlight",r)
+;const l=r.result?r.result:f(r.language,r.code,n,i)
+;return l.code=r.code,M("after:highlight",l),l}function f(e,t,s,l){
+function c(e,t){const n=v.case_insensitive?t[0].toLowerCase():t[0]
 ;return Object.prototype.hasOwnProperty.call(e.keywords,n)&&e.keywords[n]}
-function d(){null!=_.subLanguage?(()=>{if(""===M)return;let e=null
-;if("string"==typeof _.subLanguage){
-if(!n[_.subLanguage])return void O.addText(M)
-;e=f(_.subLanguage,M,!0,k[_.subLanguage]),k[_.subLanguage]=e.top
-}else e=p(M,_.subLanguage.length?_.subLanguage:null)
-;_.relevance>0&&(A+=e.relevance),O.addSublanguage(e.emitter,e.language)
-})():(()=>{if(!_.keywords)return void O.addText(M);let e=0
-;_.keywordPatternRe.lastIndex=0;let t=_.keywordPatternRe.exec(M),n="";for(;t;){
-n+=M.substring(e,t.index);const i=g(_,t);if(i){const[e,r]=i
-;O.addText(n),n="",A+=r;const s=w.classNameAliases[e]||e;O.addKeyword(t[0],s)
-}else n+=t[0];e=_.keywordPatternRe.lastIndex,t=_.keywordPatternRe.exec(M)}
-n+=M.substr(e),O.addText(n)})(),M=""}function h(e){
-return e.className&&O.openNode(w.classNameAliases[e.className]||e.className),
-_=Object.create(e,{parent:{value:_}}),_}function m(e,t,n){let r=((e,t)=>{
-const n=e&&e.exec(t);return n&&0===n.index})(e.endRe,n);if(r){if(e["on:end"]){
-const n=new i(e);e["on:end"](t,n),n.ignore&&(r=!1)}if(r){
+function g(){null!=R.subLanguage?(()=>{if(""===M)return;let e=null
+;if("string"==typeof R.subLanguage){
+if(!n[R.subLanguage])return void k.addText(M)
+;e=f(R.subLanguage,M,!0,_[R.subLanguage]),_[R.subLanguage]=e.top
+}else e=p(M,R.subLanguage.length?R.subLanguage:null)
+;R.relevance>0&&(O+=e.relevance),k.addSublanguage(e.emitter,e.language)
+})():(()=>{if(!R.keywords)return void k.addText(M);let e=0
+;R.keywordPatternRe.lastIndex=0;let t=R.keywordPatternRe.exec(M),n="";for(;t;){
+n+=M.substring(e,t.index);const i=c(R,t);if(i){const[e,s]=i
+;if(k.addText(n),n="",O+=s,e.startsWith("_"))n+=t[0];else{
+const n=v.classNameAliases[e]||e;k.addKeyword(t[0],n)}}else n+=t[0]
+;e=R.keywordPatternRe.lastIndex,t=R.keywordPatternRe.exec(M)}
+n+=M.substr(e),k.addText(n)})(),M=""}function h(e){
+return e.className&&k.openNode(v.classNameAliases[e.className]||e.className),
+R=Object.create(e,{parent:{value:R}}),R}function d(e,t,n){let s=((e,t)=>{
+const n=e&&e.exec(t);return n&&0===n.index})(e.endRe,n);if(s){if(e["on:end"]){
+const n=new i(e);e["on:end"](t,n),n.isMatchIgnored&&(s=!1)}if(s){
 for(;e.endsParent&&e.parent;)e=e.parent;return e}}
-if(e.endsWithParent)return m(e.parent,t,n)}function b(e){
-return 0===_.matcher.regexIndex?(M+=e[0],1):(B=!0,0)}function E(e){
-const t=e[0],n=c.substr(e.index),i=m(_,e,n);if(!i)return V;const r=_
-;r.skip?M+=t:(r.returnEnd||r.excludeEnd||(M+=t),d(),r.excludeEnd&&(M=t));do{
-_.className&&O.closeNode(),_.skip||_.subLanguage||(A+=_.relevance),_=_.parent
-}while(_!==i.parent)
-;return i.starts&&(i.endSameAsBegin&&(i.starts.endRe=i.endRe),
-h(i.starts)),r.returnEnd?0:t.length}let x={};function v(t,n){const s=n&&n[0]
-;if(M+=t,null==s)return d(),0
-;if("begin"===x.type&&"end"===n.type&&x.index===n.index&&""===s){
-if(M+=c.slice(n.index,n.index+1),!a){const t=Error("0 width match regex")
-;throw t.languageName=e,t.badRule=x.rule,t}return 1}
-if(x=n,"begin"===n.type)return function(e){
-const t=e[0],n=e.rule,r=new i(n),s=[n.__beforeBegin,n["on:begin"]]
-;for(const n of s)if(n&&(n(e,r),r.ignore))return b(t)
+if(e.endsWithParent)return d(e.parent,t,n)}function m(e){
+return 0===R.matcher.regexIndex?(M+=e[0],1):(I=!0,0)}function b(e){
+const n=e[0],i=t.substr(e.index),s=d(R,e,i);if(!s)return W;const a=R
+;a.skip?M+=n:(a.returnEnd||a.excludeEnd||(M+=n),g(),a.excludeEnd&&(M=n));do{
+R.className&&k.closeNode(),R.skip||R.subLanguage||(O+=R.relevance),R=R.parent
+}while(R!==s.parent)
+;return s.starts&&(s.endSameAsBegin&&(s.starts.endRe=s.endRe),
+h(s.starts)),a.returnEnd?0:n.length}let E={};function x(n,a){const l=a&&a[0]
+;if(M+=n,null==l)return g(),0
+;if("begin"===E.type&&"end"===a.type&&E.index===a.index&&""===l){
+if(M+=t.slice(a.index,a.index+1),!r){const t=Error("0 width match regex")
+;throw t.languageName=e,t.badRule=E.rule,t}return 1}
+if(E=a,"begin"===a.type)return function(e){
+const t=e[0],n=e.rule,s=new i(n),a=[n.__beforeBegin,n["on:begin"]]
+;for(const n of a)if(n&&(n(e,s),s.isMatchIgnored))return m(t)
 ;return n&&n.endSameAsBegin&&(n.endRe=RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")),
 n.skip?M+=t:(n.excludeBegin&&(M+=t),
-d(),n.returnBegin||n.excludeBegin||(M=t)),h(n),n.returnBegin?0:t.length}(n)
-;if("illegal"===n.type&&!r){
-const e=Error('Illegal lexeme "'+s+'" for mode "'+(_.className||"<unnamed>")+'"')
-;throw e.mode=_,e}if("end"===n.type){const e=E(n);if(e!==V)return e}
-if("illegal"===n.type&&""===s)return 1
-;if(j>1e5&&j>3*n.index)throw Error("potential infinite loop, way more iterations than matches")
-;return M+=s,s.length}const w=R(e)
-;if(!w)throw $(o.replace("{}",e)),Error('Unknown language: "'+e+'"')
-;const y=T(w,{plugins:s});let N="",_=l||y;const k={},O=new u.__emitter(u);(()=>{
-const e=[];for(let t=_;t!==w;t=t.parent)t.className&&e.unshift(t.className)
-;e.forEach((e=>O.openNode(e)))})();let M="",A=0,L=0,j=0,B=!1;try{
-for(_.matcher.considerAll();;){
-j++,B?B=!1:_.matcher.considerAll(),_.matcher.lastIndex=L
-;const e=_.matcher.exec(c);if(!e)break;const t=v(c.substring(L,e.index),e)
-;L=e.index+t}return v(c.substr(L)),O.closeAllNodes(),O.finalize(),N=O.toHTML(),{
-relevance:Math.floor(A),value:N,language:e,illegal:!1,emitter:O,top:_}}catch(t){
-if(t.message&&t.message.includes("Illegal"))return{illegal:!0,illegalBy:{
-msg:t.message,context:c.slice(L-100,L+100),mode:t.mode},sofar:N,relevance:0,
-value:K(c),emitter:O};if(a)return{illegal:!1,relevance:0,value:K(c),emitter:O,
-language:e,top:_,errorRaised:t};throw t}}function p(e,t){
+g(),n.returnBegin||n.excludeBegin||(M=t)),h(n),n.returnBegin?0:t.length}(a)
+;if("illegal"===a.type&&!s){
+const e=Error('Illegal lexeme "'+l+'" for mode "'+(R.className||"<unnamed>")+'"')
+;throw e.mode=R,e}if("end"===a.type){const e=b(a);if(e!==W)return e}
+if("illegal"===a.type&&""===l)return 1
+;if(L>1e5&&L>3*a.index)throw Error("potential infinite loop, way more iterations than matches")
+;return M+=l,l.length}const v=N(e)
+;if(!v)throw U(o.replace("{}",e)),Error('Unknown language: "'+e+'"')
+;const w=T(v,{plugins:a});let y="",R=l||w;const _={},k=new u.__emitter(u);(()=>{
+const e=[];for(let t=R;t!==v;t=t.parent)t.className&&e.unshift(t.className)
+;e.forEach((e=>k.openNode(e)))})();let M="",O=0,A=0,L=0,I=!1;try{
+for(R.matcher.considerAll();;){
+L++,I?I=!1:R.matcher.considerAll(),R.matcher.lastIndex=A
+;const e=R.matcher.exec(t);if(!e)break;const n=x(t.substring(A,e.index),e)
+;A=e.index+n}return x(t.substr(A)),k.closeAllNodes(),k.finalize(),y=k.toHTML(),{
+relevance:Math.floor(O),value:y,language:e,illegal:!1,emitter:k,top:R}}catch(n){
+if(n.message&&n.message.includes("Illegal"))return{illegal:!0,illegalBy:{
+msg:n.message,context:t.slice(A-100,A+100),mode:n.mode},sofar:y,relevance:0,
+value:G(t),emitter:k};if(r)return{illegal:!1,relevance:0,value:G(t),emitter:k,
+language:e,top:R,errorRaised:n};throw n}}function p(e,t){
 t=t||u.languages||Object.keys(n);const i=(e=>{const t={relevance:0,
-emitter:new u.__emitter(u),value:K(e),illegal:!1,top:g}
-;return t.emitter.addText(e),t})(e),r=t.filter(R).filter(O).map((t=>f(t,e,!1)))
-;r.unshift(i);const s=r.sort(((e,t)=>{
+emitter:new u.__emitter(u),value:G(e),illegal:!1,top:g}
+;return t.emitter.addText(e),t})(e),s=t.filter(N).filter(k).map((t=>f(t,e,!1)))
+;s.unshift(i);const a=s.sort(((e,t)=>{
 if(e.relevance!==t.relevance)return t.relevance-e.relevance
-;if(e.language&&t.language){if(R(e.language).supersetOf===t.language)return 1
-;if(R(t.language).supersetOf===e.language)return-1}return 0})),[a,l]=s,o=a
+;if(e.language&&t.language){if(N(e.language).supersetOf===t.language)return 1
+;if(N(t.language).supersetOf===e.language)return-1}return 0})),[r,l]=a,o=r
 ;return o.second_best=l,o}const m={"before:highlightElement":({el:e})=>{
 u.useBR&&(e.innerHTML=e.innerHTML.replace(/\n/g,"").replace(/<br[ /]*>/g,"\n"))
 },"after:highlightElement":({result:e})=>{
@@ -249,56 +256,58 @@
 u.tabReplace&&(e.value=e.value.replace(b,(e=>e.replace(/\t/g,u.tabReplace))))}}
 ;function x(e){let t=null;const n=(e=>{let t=e.className+" "
 ;t+=e.parentNode?e.parentNode.className:"";const n=u.languageDetectRe.exec(t)
-;if(n){const t=R(n[1])
-;return t||(U(o.replace("{}",n[1])),U("Falling back to no-highlight mode for this block.",e)),
-t?n[1]:"no-highlight"}return t.split(/\s+/).find((e=>d(e)||R(e)))})(e)
-;if(d(n))return;M("before:highlightElement",{el:e,language:n}),t=e
-;const i=t.textContent,s=n?h(n,i,!0):p(i);M("after:highlightElement",{el:e,
-result:s,text:i}),e.innerHTML=s.value,((e,t,n)=>{const i=t?r[t]:n
-;e.classList.add("hljs"),i&&e.classList.add(i)})(e,n,s.language),e.result={
-language:s.language,re:s.relevance,relavance:s.relevance
-},s.second_best&&(e.second_best={language:s.second_best.language,
-re:s.second_best.relevance,relavance:s.second_best.relevance})}const v=()=>{
+;if(n){const t=N(n[1])
+;return t||(z(o.replace("{}",n[1])),z("Falling back to no-highlight mode for this block.",e)),
+t?n[1]:"no-highlight"}return t.split(/\s+/).find((e=>h(e)||N(e)))})(e)
+;if(h(n))return;M("before:highlightElement",{el:e,language:n}),t=e
+;const i=t.textContent,a=n?d(i,{language:n,ignoreIllegals:!0}):p(i)
+;M("after:highlightElement",{el:e,result:a,text:i
+}),e.innerHTML=a.value,((e,t,n)=>{const i=t?s[t]:n
+;e.classList.add("hljs"),i&&e.classList.add(i)})(e,n,a.language),e.result={
+language:a.language,re:a.relevance,relavance:a.relevance
+},a.second_best&&(e.second_best={language:a.second_best.language,
+re:a.second_best.relevance,relavance:a.second_best.relevance})}const v=()=>{
 v.called||(v.called=!0,
-z("10.6.0","initHighlighting() is deprecated.  Use highlightAll() instead."),
-document.querySelectorAll("pre code").forEach(x))};let w=!1,y=!1;function N(){
-y?document.querySelectorAll("pre code").forEach(x):w=!0}function R(e){
-return e=(e||"").toLowerCase(),n[e]||n[r[e]]}function k(e,{languageName:t}){
-"string"==typeof e&&(e=[e]),e.forEach((e=>{r[e.toLowerCase()]=t}))}
-function O(e){const t=R(e);return t&&!t.disableAutodetect}function M(e,t){
-const n=e;s.forEach((e=>{e[n]&&e[n](t)}))}
+K("10.6.0","initHighlighting() is deprecated.  Use highlightAll() instead."),
+document.querySelectorAll("pre code").forEach(x))};let w=!1;function y(){
+"loading"!==document.readyState?document.querySelectorAll("pre code").forEach(x):w=!0
+}function N(e){return e=(e||"").toLowerCase(),n[e]||n[s[e]]}
+function R(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{
+s[e.toLowerCase()]=t}))}function k(e){const t=N(e)
+;return t&&!t.disableAutodetect}function M(e,t){const n=e;a.forEach((e=>{
+e[n]&&e[n](t)}))}
 "undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{
-y=!0,w&&N()}),!1),Object.assign(e,{highlight:h,highlightAuto:p,highlightAll:N,
+w&&y()}),!1),Object.assign(e,{highlight:d,highlightAuto:p,highlightAll:y,
 fixMarkup:e=>{
-return z("10.2.0","fixMarkup will be removed entirely in v11.0"),z("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),
+return K("10.2.0","fixMarkup will be removed entirely in v11.0"),K("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),
 t=e,
 u.tabReplace||u.useBR?t.replace(l,(e=>"\n"===e?u.useBR?"<br>":e:u.tabReplace?e.replace(/\t/g,u.tabReplace):e)):t
 ;var t},highlightElement:x,
-highlightBlock:e=>(z("10.7.0","highlightBlock will be removed entirely in v12.0"),
-z("10.7.0","Please use highlightElement now."),x(e)),configure:e=>{
-e.useBR&&(z("10.3.0","'useBR' will be removed entirely in v11.0"),
-z("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),
-u=G(u,e)},initHighlighting:v,initHighlightingOnLoad:()=>{
-z("10.6.0","initHighlightingOnLoad() is deprecated.  Use highlightAll() instead."),
-w=!0},registerLanguage:(t,i)=>{let r=null;try{r=i(e)}catch(e){
-if($("Language definition for '{}' could not be registered.".replace("{}",t)),
-!a)throw e;$(e),r=g}
-r.name||(r.name=t),n[t]=r,r.rawDefinition=i.bind(null,e),r.aliases&&k(r.aliases,{
+highlightBlock:e=>(K("10.7.0","highlightBlock will be removed entirely in v12.0"),
+K("10.7.0","Please use highlightElement now."),x(e)),configure:e=>{
+e.useBR&&(K("10.3.0","'useBR' will be removed entirely in v11.0"),
+K("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),
+u=V(u,e)},initHighlighting:v,initHighlightingOnLoad:()=>{
+K("10.6.0","initHighlightingOnLoad() is deprecated.  Use highlightAll() instead."),
+w=!0},registerLanguage:(t,i)=>{let s=null;try{s=i(e)}catch(e){
+if(U("Language definition for '{}' could not be registered.".replace("{}",t)),
+!r)throw e;U(e),s=g}
+s.name||(s.name=t),n[t]=s,s.rawDefinition=i.bind(null,e),s.aliases&&R(s.aliases,{
 languageName:t})},unregisterLanguage:e=>{delete n[e]
-;for(const t of Object.keys(r))r[t]===e&&delete r[t]},
-listLanguages:()=>Object.keys(n),getLanguage:R,registerAliases:k,
+;for(const t of Object.keys(s))s[t]===e&&delete s[t]},
+listLanguages:()=>Object.keys(n),getLanguage:N,registerAliases:R,
 requireLanguage:e=>{
-z("10.4.0","requireLanguage will be removed entirely in v11."),
-z("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844")
-;const t=R(e);if(t)return t
+K("10.4.0","requireLanguage will be removed entirely in v11."),
+K("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844")
+;const t=N(e);if(t)return t
 ;throw Error("The '{}' language is required, but not loaded.".replace("{}",e))},
-autoDetection:O,inherit:G,addPlugin:e=>{(e=>{
+autoDetection:k,inherit:V,addPlugin:e=>{(e=>{
 e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{
 e["before:highlightBlock"](Object.assign({block:t.el},t))
 }),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{
-e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),s.push(e)},
-vuePlugin:P(e).VuePlugin}),e.debugMode=()=>{a=!1},e.safeMode=()=>{a=!0
-},e.versionString="10.6.0";for(const e in _)"object"==typeof _[e]&&t(_[e])
+e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),a.push(e)},
+vuePlugin:P(e).VuePlugin}),e.debugMode=()=>{r=!1},e.safeMode=()=>{r=!0
+},e.versionString="10.7.2";for(const e in _)"object"==typeof _[e]&&t(_[e])
 ;return Object.assign(e,_),e.addPlugin(m),e.addPlugin(D),e.addPlugin(E),e})({})
 }();"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);
 hljs.registerLanguage("1c",(()=>{"use strict";return s=>{
@@ -454,8 +463,8 @@
 className:"subst",begin:"\\$\\{",end:"\\}",keywords:a,contains:[]},r={
 className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,i]}
 ;i.contains=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,t,e.REGEXP_MODE]
-;const s=i.contains.concat([e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE])
-;return{name:"ArcGIS Arcade",aliases:["arcade"],keywords:a,
+;const o=i.contains.concat([e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE])
+;return{name:"ArcGIS Arcade",keywords:a,
 contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
 className:"symbol",
 begin:"\\$[datastore|feature|layer|map|measure|sourcefeature|sourcelayer|targetfeature|targetlayer|value|view]+"
@@ -465,56 +474,63 @@
 contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.REGEXP_MODE,{
 className:"function",begin:"(\\(.*?\\)|"+n+")\\s*=>",returnBegin:!0,
 end:"\\s*=>",contains:[{className:"params",variants:[{begin:n},{begin:/\(\s*\)/
-},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:a,contains:s}]}]
+},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:a,contains:o}]}]
 }],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,
 excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:n}),{className:"params",
-begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:s}],illegal:/\[|%/},{
+begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:o}],illegal:/\[|%/},{
 begin:/\$[(.]/}],illegal:/#(?!!)/}}})());
 hljs.registerLanguage("arduino",(()=>{"use strict";function e(e){
-return((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(",e,")?")
-}return t=>{const r=(t=>{const r=t.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),n="[a-zA-Z_]\\w*::",i="(decltype\\(auto\\)|"+e(n)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",a={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},s={className:"string",
+return t("(",e,")?")}function t(...e){return e.map((e=>{
+return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return r=>{
+const n=(r=>{const n=r.COMMENT("//","$",{contains:[{begin:/\\\n/}]
+}),i="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+e(i)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={
+className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",
 variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[t.BACKSLASH_ESCAPE]},{
+contains:[r.BACKSLASH_ESCAPE]},{
 begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},t.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
+end:"'",illegal:"."},r.END_SAME_AS_BEGIN({
+begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={
 className:"number",variants:[{begin:"\\b(0b[01']+)"},{
 begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
 },{
 begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
+}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
 "meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},t.inherit(s,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/,end:/$/,illegal:"\\n"
-},r,t.C_BLOCK_COMMENT_MODE]},c={className:"title",begin:e(n)+t.IDENT_RE,
-relevance:0},d=e(n)+t.IDENT_RE+"\\s*\\(",u={
+},contains:[{begin:/\\\n/,relevance:0},r.inherit(o,{className:"meta-string"}),{
+className:"meta-string",begin:/<.*?>/},n,r.C_BLOCK_COMMENT_MODE]},d={
+className:"title",begin:e(i)+r.IDENT_RE,relevance:0
+},u=e(i)+r.IDENT_RE+"\\s*\\(",m={
 keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",
-literal:"true false nullptr NULL"},m=[l,a,r,t.C_BLOCK_COMMENT_MODE,o,s],p={
+built_in:"_Bool _Complex _Imaginary",
+_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
+literal:"true false nullptr NULL"},p={className:"function.dispatch",relevance:0,
+keywords:m,
+begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,r.IDENT_RE,(g=/\s*\(/,
+t("(?=",g,")")))};var g;const b=[p,c,s,n,r.C_BLOCK_COMMENT_MODE,l,o],_={
 variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:u,contains:m.concat([{
-begin:/\(/,end:/\)/,keywords:u,contains:m.concat(["self"]),relevance:0}]),
-relevance:0},g={className:"function",begin:"("+i+"[\\*&\\s]+)+"+d,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:u,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:u,relevance:0},{begin:d,
-returnBegin:!0,contains:[c],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[s,o]},{className:"params",begin:/\(/,end:/\)/,
-keywords:u,relevance:0,contains:[r,t.C_BLOCK_COMMENT_MODE,s,o,a,{begin:/\(/,
-end:/\)/,keywords:u,relevance:0,contains:["self",r,t.C_BLOCK_COMMENT_MODE,s,o,a]
-}]},a,r,t.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"</",
-contains:[].concat(p,g,m,[l,{
+beginKeywords:"new throw return else",end:/;/}],keywords:m,contains:b.concat([{
+begin:/\(/,end:/\)/,keywords:m,contains:b.concat(["self"]),relevance:0}]),
+relevance:0},y={className:"function",begin:"("+a+"[\\*&\\s]+)+"+u,
+returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\w\s\*&:<>.]/,
+contains:[{begin:"decltype\\(auto\\)",keywords:m,relevance:0},{begin:u,
+returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
+endsWithParent:!0,contains:[o,l]},{className:"params",begin:/\(/,end:/\)/,
+keywords:m,relevance:0,contains:[n,r.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,
+end:/\)/,keywords:m,relevance:0,contains:["self",n,r.C_BLOCK_COMMENT_MODE,o,l,s]
+}]},s,n,r.C_BLOCK_COMMENT_MODE,c]};return{name:"C++",
+aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:m,illegal:"</",
+classNameAliases:{"function.dispatch":"built_in"},
+contains:[].concat(_,y,p,b,[c,{
 begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:u,contains:["self",a]},{begin:t.IDENT_RE+"::",keywords:u},{
+end:">",keywords:m,contains:["self",s]},{begin:r.IDENT_RE+"::",keywords:m},{
 className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},t.TITLE_MODE]}]),exports:{
-preprocessor:l,strings:s,keywords:u}}})(t),n=r.keywords
-;return n.keyword+=" boolean byte word String",
-n.literal+=" DIGITAL_MESSAGE FIRMATA_STRING ANALOG_MESSAGE REPORT_DIGITAL REPORT_ANALOG INPUT_PULLUP SET_PIN_MODE INTERNAL2V56 SYSTEM_RESET LED_BUILTIN INTERNAL1V1 SYSEX_START INTERNAL EXTERNAL DEFAULT OUTPUT INPUT HIGH LOW",
-n.built_in+=" setup loop KeyboardController MouseController SoftwareSerial EthernetServer EthernetClient LiquidCrystal RobotControl GSMVoiceCall EthernetUDP EsploraTFT HttpClient RobotMotor WiFiClient GSMScanner FileSystem Scheduler GSMServer YunClient YunServer IPAddress GSMClient GSMModem Keyboard Ethernet Console GSMBand Esplora Stepper Process WiFiUDP GSM_SMS Mailbox USBHost Firmata PImage Client Server GSMPIN FileIO Bridge Serial EEPROM Stream Mouse Audio Servo File Task GPRS WiFi Wire TFT GSM SPI SD runShellCommandAsynchronously analogWriteResolution retrieveCallingNumber printFirmwareVersion analogReadResolution sendDigitalPortPair noListenOnLocalhost readJoystickButton setFirmwareVersion readJoystickSwitch scrollDisplayRight getVoiceCallStatus scrollDisplayLeft writeMicroseconds delayMicroseconds beginTransmission getSignalStrength runAsynchronously getAsynchronously listenOnLocalhost getCurrentCarrier readAccelerometer messageAvailable sendDigitalPorts lineFollowConfig countryNameWrite runShellCommand readStringUntil rewindDirectory readTemperature setClockDivider readLightSensor endTransmission analogReference detachInterrupt countryNameRead attachInterrupt encryptionType readBytesUntil robotNameWrite readMicrophone robotNameRead cityNameWrite userNameWrite readJoystickY readJoystickX mouseReleased openNextFile scanNetworks noInterrupts digitalWrite beginSpeaker mousePressed isActionDone mouseDragged displayLogos noAutoscroll addParameter remoteNumber getModifiers keyboardRead userNameRead waitContinue processInput parseCommand printVersion readNetworks writeMessage blinkVersion cityNameRead readMessage setDataMode parsePacket isListening setBitOrder beginPacket isDirectory motorsWrite drawCompass digitalRead clearScreen serialEvent rightToLeft setTextSize leftToRight requestFrom keyReleased compassRead analogWrite interrupts WiFiServer disconnect playMelody parseFloat autoscroll getPINUsed setPINUsed setTimeout sendAnalog readSlider analogRead beginWrite createChar motorsStop keyPressed tempoWrite readButton subnetMask debugPrint macAddress writeGreen randomSeed attachGPRS readString sendString remotePort releaseAll mouseMoved background getXChange getYChange answerCall getResult voiceCall endPacket constrain getSocket writeJSON getButton available connected findUntil readBytes exitValue readGreen writeBlue startLoop IPAddress isPressed sendSysex pauseMode gatewayIP setCursor getOemKey tuneWrite noDisplay loadImage switchPIN onRequest onReceive changePIN playFile noBuffer parseInt overflow checkPIN knobRead beginTFT bitClear updateIR bitWrite position writeRGB highByte writeRed setSpeed readBlue noStroke remoteIP transfer shutdown hangCall beginSMS endWrite attached maintain noCursor checkReg checkPUK shiftOut isValid shiftIn pulseIn connect println localIP pinMode getIMEI display noBlink process getBand running beginSD drawBMP lowByte setBand release bitRead prepare pointTo readRed setMode noFill remove listen stroke detach attach noTone exists buffer height bitSet circle config cursor random IRread setDNS endSMS getKey micros millis begin print write ready flush width isPIN blink clear press mkdir rmdir close point yield image BSSID click delay read text move peek beep rect line open seek fill size turn stop home find step tone sqrt RSSI SSID end bit tan cos sin pow map abs max min get run put",
-r.name="Arduino",r.aliases=["ino"],r.supersetOf="cpp",r}})());
+contains:[{beginKeywords:"final class struct"},r.TITLE_MODE]}]),exports:{
+preprocessor:c,strings:o,keywords:m}}})(r),i=n.keywords
+;return i.keyword+=" boolean byte word String",
+i.literal+=" DIGITAL_MESSAGE FIRMATA_STRING ANALOG_MESSAGE REPORT_DIGITAL REPORT_ANALOG INPUT_PULLUP SET_PIN_MODE INTERNAL2V56 SYSTEM_RESET LED_BUILTIN INTERNAL1V1 SYSEX_START INTERNAL EXTERNAL DEFAULT OUTPUT INPUT HIGH LOW",
+i.built_in+=" KeyboardController MouseController SoftwareSerial EthernetServer EthernetClient LiquidCrystal RobotControl GSMVoiceCall EthernetUDP EsploraTFT HttpClient RobotMotor WiFiClient GSMScanner FileSystem Scheduler GSMServer YunClient YunServer IPAddress GSMClient GSMModem Keyboard Ethernet Console GSMBand Esplora Stepper Process WiFiUDP GSM_SMS Mailbox USBHost Firmata PImage Client Server GSMPIN FileIO Bridge Serial EEPROM Stream Mouse Audio Servo File Task GPRS WiFi Wire TFT GSM SPI SD ",
+i._+=" setup loop runShellCommandAsynchronously analogWriteResolution retrieveCallingNumber printFirmwareVersion analogReadResolution sendDigitalPortPair noListenOnLocalhost readJoystickButton setFirmwareVersion readJoystickSwitch scrollDisplayRight getVoiceCallStatus scrollDisplayLeft writeMicroseconds delayMicroseconds beginTransmission getSignalStrength runAsynchronously getAsynchronously listenOnLocalhost getCurrentCarrier readAccelerometer messageAvailable sendDigitalPorts lineFollowConfig countryNameWrite runShellCommand readStringUntil rewindDirectory readTemperature setClockDivider readLightSensor endTransmission analogReference detachInterrupt countryNameRead attachInterrupt encryptionType readBytesUntil robotNameWrite readMicrophone robotNameRead cityNameWrite userNameWrite readJoystickY readJoystickX mouseReleased openNextFile scanNetworks noInterrupts digitalWrite beginSpeaker mousePressed isActionDone mouseDragged displayLogos noAutoscroll addParameter remoteNumber getModifiers keyboardRead userNameRead waitContinue processInput parseCommand printVersion readNetworks writeMessage blinkVersion cityNameRead readMessage setDataMode parsePacket isListening setBitOrder beginPacket isDirectory motorsWrite drawCompass digitalRead clearScreen serialEvent rightToLeft setTextSize leftToRight requestFrom keyReleased compassRead analogWrite interrupts WiFiServer disconnect playMelody parseFloat autoscroll getPINUsed setPINUsed setTimeout sendAnalog readSlider analogRead beginWrite createChar motorsStop keyPressed tempoWrite readButton subnetMask debugPrint macAddress writeGreen randomSeed attachGPRS readString sendString remotePort releaseAll mouseMoved background getXChange getYChange answerCall getResult voiceCall endPacket constrain getSocket writeJSON getButton available connected findUntil readBytes exitValue readGreen writeBlue startLoop IPAddress isPressed sendSysex pauseMode gatewayIP setCursor getOemKey tuneWrite noDisplay loadImage switchPIN onRequest onReceive changePIN playFile noBuffer parseInt overflow checkPIN knobRead beginTFT bitClear updateIR bitWrite position writeRGB highByte writeRed setSpeed readBlue noStroke remoteIP transfer shutdown hangCall beginSMS endWrite attached maintain noCursor checkReg checkPUK shiftOut isValid shiftIn pulseIn connect println localIP pinMode getIMEI display noBlink process getBand running beginSD drawBMP lowByte setBand release bitRead prepare pointTo readRed setMode noFill remove listen stroke detach attach noTone exists buffer height bitSet circle config cursor random IRread setDNS endSMS getKey micros millis begin print write ready flush width isPIN blink clear press mkdir rmdir close point yield image BSSID click delay read text move peek beep rect line open seek fill size turn stop home find step tone sqrt RSSI SSID end bit tan cos sin pow map abs max min get run put",
+n.name="Arduino",n.aliases=["ino"],n.supersetOf="cpp",n}})());
 hljs.registerLanguage("armasm",(()=>{"use strict";return s=>{const e={
 variants:[s.COMMENT("^[ \\t]*(?=#)","$",{relevance:0,excludeBegin:!0
 }),s.COMMENT("[;@]","$",{relevance:0
@@ -557,8 +573,8 @@
 className:"tag",begin:/<>|<\/>/},{className:"tag",
 begin:a(/</,n(a(t,s(/\/>/,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",
 begin:t,relevance:0,starts:m}]},{className:"tag",begin:a(/<\//,n(a(t,/>/))),
-contains:[{className:"name",begin:t,relevance:0},{begin:/>/,relevance:0}]}]}}
-})());
+contains:[{className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,
+endsParent:!0}]}]}}})());
 hljs.registerLanguage("asciidoc",(()=>{"use strict";function e(...e){
 return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
 })).join("")}return n=>{const a=[{className:"strong",begin:/\*{2}([^\n]+?)\*{2}/
@@ -728,49 +744,55 @@
 className:"string",begin:"[\\.,]",relevance:0},{begin:/(?:\+\+|--)/,contains:[n]
 },n]}}})());
 hljs.registerLanguage("c-like",(()=>{"use strict";function e(e){
-return((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(",e,")?")
-}return t=>{const n=(t=>{const n=t.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),a="[a-zA-Z_]\\w*::",r="(decltype\\(auto\\)|"+e(a)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},i={className:"string",
+return t("(",e,")?")}function t(...e){return e.map((e=>{
+return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return n=>{
+const a=(n=>{const a=n.COMMENT("//","$",{contains:[{begin:/\\\n/}]
+}),r="[a-zA-Z_]\\w*::",s="(decltype\\(auto\\)|"+e(r)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",i={
+className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",
 variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[t.BACKSLASH_ESCAPE]},{
+contains:[n.BACKSLASH_ESCAPE]},{
 begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},t.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},c={
+end:"'",illegal:"."},n.END_SAME_AS_BEGIN({
+begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
 className:"number",variants:[{begin:"\\b(0b[01']+)"},{
 begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
 },{
 begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},o={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
+}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
 "meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},t.inherit(i,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/,end:/$/,illegal:"\\n"
-},n,t.C_BLOCK_COMMENT_MODE]},l={className:"title",begin:e(a)+t.IDENT_RE,
-relevance:0},d=e(a)+t.IDENT_RE+"\\s*\\(",u={
+},contains:[{begin:/\\\n/,relevance:0},n.inherit(c,{className:"meta-string"}),{
+className:"meta-string",begin:/<.*?>/},a,n.C_BLOCK_COMMENT_MODE]},d={
+className:"title",begin:e(r)+n.IDENT_RE,relevance:0
+},u=e(r)+n.IDENT_RE+"\\s*\\(",p={
 keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",
-literal:"true false nullptr NULL"},p=[o,s,n,t.C_BLOCK_COMMENT_MODE,c,i],m={
+built_in:"_Bool _Complex _Imaginary",
+_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
+literal:"true false nullptr NULL"},m={className:"function.dispatch",relevance:0,
+keywords:p,
+begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\s*\(/,
+t("(?=",_,")")))};var _;const g=[m,l,i,a,n.C_BLOCK_COMMENT_MODE,o,c],b={
 variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:u,contains:p.concat([{
-begin:/\(/,end:/\)/,keywords:u,contains:p.concat(["self"]),relevance:0}]),
-relevance:0},g={className:"function",begin:"("+r+"[\\*&\\s]+)+"+d,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:u,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:u,relevance:0},{begin:d,
-returnBegin:!0,contains:[l],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[i,c]},{className:"params",begin:/\(/,end:/\)/,
-keywords:u,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,i,c,s,{begin:/\(/,
-end:/\)/,keywords:u,relevance:0,contains:["self",n,t.C_BLOCK_COMMENT_MODE,i,c,s]
-}]},s,n,t.C_BLOCK_COMMENT_MODE,o]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"</",
-contains:[].concat(m,g,p,[o,{
+beginKeywords:"new throw return else",end:/;/}],keywords:p,contains:g.concat([{
+begin:/\(/,end:/\)/,keywords:p,contains:g.concat(["self"]),relevance:0}]),
+relevance:0},f={className:"function",begin:"("+s+"[\\*&\\s]+)+"+u,
+returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:p,illegal:/[^\w\s\*&:<>.]/,
+contains:[{begin:"decltype\\(auto\\)",keywords:p,relevance:0},{begin:u,
+returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
+endsWithParent:!0,contains:[c,o]},{className:"params",begin:/\(/,end:/\)/,
+keywords:p,relevance:0,contains:[a,n.C_BLOCK_COMMENT_MODE,c,o,i,{begin:/\(/,
+end:/\)/,keywords:p,relevance:0,contains:["self",a,n.C_BLOCK_COMMENT_MODE,c,o,i]
+}]},i,a,n.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
+aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:p,illegal:"</",
+classNameAliases:{"function.dispatch":"built_in"},
+contains:[].concat(b,f,m,g,[l,{
 begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:u,contains:["self",s]},{begin:t.IDENT_RE+"::",keywords:u},{
+end:">",keywords:p,contains:["self",i]},{begin:n.IDENT_RE+"::",keywords:p},{
 className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},t.TITLE_MODE]}]),exports:{
-preprocessor:o,strings:i,keywords:u}}})(t)
-;return n.disableAutodetect=!0,n.aliases=[],
-t.getLanguage("c")||n.aliases.push("c","h"),
-t.getLanguage("cpp")||n.aliases.push("cc","c++","h++","hpp","hh","hxx","cxx"),n}
+contains:[{beginKeywords:"final class struct"},n.TITLE_MODE]}]),exports:{
+preprocessor:l,strings:c,keywords:p}}})(n)
+;return a.disableAutodetect=!0,a.aliases=[],
+n.getLanguage("c")||a.aliases.push("c","h"),
+n.getLanguage("cpp")||a.aliases.push("cc","c++","h++","hpp","hh","hxx","cxx"),a}
 })());
 hljs.registerLanguage("c",(()=>{"use strict";function e(e){
 return((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(",e,")?")
@@ -789,9 +811,9 @@
 }],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
 "meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
 },contains:[{begin:/\\\n/,relevance:0},t.inherit(s,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/,end:/$/,illegal:"\\n"
-},n,t.C_BLOCK_COMMENT_MODE]},l={className:"title",begin:e(r)+t.IDENT_RE,
-relevance:0},d=e(r)+t.IDENT_RE+"\\s*\\(",u={
+className:"meta-string",begin:/<.*?>/},n,t.C_BLOCK_COMMENT_MODE]},l={
+className:"title",begin:e(r)+t.IDENT_RE,relevance:0
+},d=e(r)+t.IDENT_RE+"\\s*\\(",u={
 keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
 built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",
 literal:"true false nullptr NULL"},m=[c,i,n,t.C_BLOCK_COMMENT_MODE,o,s],p={
@@ -805,7 +827,7 @@
 end:/\)/,keywords:u,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,s,o,i,{
 begin:/\(/,end:/\)/,keywords:u,relevance:0,
 contains:["self",n,t.C_BLOCK_COMMENT_MODE,s,o,i]}]
-},i,n,t.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["c","h"],keywords:u,
+},i,n,t.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u,
 disableAutodetect:!0,illegal:"</",contains:[].concat(p,_,m,[c,{
 begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
 end:">",keywords:u,contains:["self",i]},{begin:t.IDENT_RE+"::",keywords:u},{
@@ -927,46 +949,52 @@
 excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"},{begin:/&html<\s*</,
 end:/>\s*>/,subLanguage:"xml"}]})})());
 hljs.registerLanguage("cpp",(()=>{"use strict";function e(e){
-return((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(",e,")?")
-}return t=>{const n=t.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),r="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+e(r)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",i={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},s={className:"string",
+return t("(",e,")?")}function t(...e){return e.map((e=>{
+return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return n=>{
+const r=n.COMMENT("//","$",{contains:[{begin:/\\\n/}]
+}),a="[a-zA-Z_]\\w*::",i="(decltype\\(auto\\)|"+e(a)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={
+className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",
 variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[t.BACKSLASH_ESCAPE]},{
+contains:[n.BACKSLASH_ESCAPE]},{
 begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},t.END_SAME_AS_BEGIN({
+end:"'",illegal:"."},n.END_SAME_AS_BEGIN({
 begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
 className:"number",variants:[{begin:"\\b(0b[01']+)"},{
 begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
 },{
 begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
+}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
 "meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},t.inherit(s,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/,end:/$/,illegal:"\\n"
-},n,t.C_BLOCK_COMMENT_MODE]},l={className:"title",begin:e(r)+t.IDENT_RE,
-relevance:0},d=e(r)+t.IDENT_RE+"\\s*\\(",u={
+},contains:[{begin:/\\\n/,relevance:0},n.inherit(c,{className:"meta-string"}),{
+className:"meta-string",begin:/<.*?>/},r,n.C_BLOCK_COMMENT_MODE]},d={
+className:"title",begin:e(a)+n.IDENT_RE,relevance:0
+},u=e(a)+n.IDENT_RE+"\\s*\\(",m={
 keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",
-literal:"true false nullptr NULL"},m=[c,i,n,t.C_BLOCK_COMMENT_MODE,o,s],p={
+built_in:"_Bool _Complex _Imaginary",
+_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
+literal:"true false nullptr NULL"},p={className:"function.dispatch",relevance:0,
+keywords:m,
+begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\s*\(/,
+t("(?=",_,")")))};var _;const g=[p,l,s,r,n.C_BLOCK_COMMENT_MODE,o,c],b={
 variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:u,contains:m.concat([{
-begin:/\(/,end:/\)/,keywords:u,contains:m.concat(["self"]),relevance:0}]),
-relevance:0},_={className:"function",begin:"("+a+"[\\*&\\s]+)+"+d,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:u,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:u,relevance:0},{begin:d,
-returnBegin:!0,contains:[l],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[s,o]},{className:"params",begin:/\(/,end:/\)/,
-keywords:u,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,s,o,i,{begin:/\(/,
-end:/\)/,keywords:u,relevance:0,contains:["self",n,t.C_BLOCK_COMMENT_MODE,s,o,i]
-}]},i,n,t.C_BLOCK_COMMENT_MODE,c]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"</",
-contains:[].concat(p,_,m,[c,{
+beginKeywords:"new throw return else",end:/;/}],keywords:m,contains:g.concat([{
+begin:/\(/,end:/\)/,keywords:m,contains:g.concat(["self"]),relevance:0}]),
+relevance:0},f={className:"function",begin:"("+i+"[\\*&\\s]+)+"+u,
+returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\w\s\*&:<>.]/,
+contains:[{begin:"decltype\\(auto\\)",keywords:m,relevance:0},{begin:u,
+returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
+endsWithParent:!0,contains:[c,o]},{className:"params",begin:/\(/,end:/\)/,
+keywords:m,relevance:0,contains:[r,n.C_BLOCK_COMMENT_MODE,c,o,s,{begin:/\(/,
+end:/\)/,keywords:m,relevance:0,contains:["self",r,n.C_BLOCK_COMMENT_MODE,c,o,s]
+}]},s,r,n.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
+aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:m,illegal:"</",
+classNameAliases:{"function.dispatch":"built_in"},
+contains:[].concat(b,f,p,g,[l,{
 begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:u,contains:["self",i]},{begin:t.IDENT_RE+"::",keywords:u},{
+end:">",keywords:m,contains:["self",s]},{begin:n.IDENT_RE+"::",keywords:m},{
 className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},t.TITLE_MODE]}]),exports:{
-preprocessor:c,strings:s,keywords:u}}}})());
+contains:[{beginKeywords:"final class struct"},n.TITLE_MODE]}]),exports:{
+preprocessor:l,strings:c,keywords:m}}}})());
 hljs.registerLanguage("crmsh",(()=>{"use strict";return e=>{
 const t="group clone ms master location colocation order fencing_topology rsc_ticket acl_target acl_group user role tag xml"
 ;return{name:"crmsh",aliases:["crm","pcmk"],case_insensitive:!0,keywords:{
@@ -1030,9 +1058,9 @@
 },{begin:"\\b([1-9][0-9_]*|0)"+n}],relevance:0}]
 ;return t.contains=g,c.contains=g.slice(1),{name:"Crystal",aliases:["cr"],
 keywords:a,contains:g}}})());
-hljs.registerLanguage("csharp",(()=>{"use strict";return e=>{var n={
+hljs.registerLanguage("csharp",(()=>{"use strict";return e=>{const n={
 keyword:["abstract","as","base","break","case","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]),
-built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","unit","ushort"],
+built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"],
 literal:["default","false","null","true"]},a=e.inherit(e.TITLE_MODE,{
 begin:"[a-zA-Z](\\.?\\w)*"}),i={className:"number",variants:[{
 begin:"\\b(0b[01']+)"},{
@@ -1047,7 +1075,7 @@
 contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},l]})
 ;r.contains=[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.C_BLOCK_COMMENT_MODE],
 l.contains=[d,c,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.inherit(e.C_BLOCK_COMMENT_MODE,{
-illegal:/\n/})];var g={variants:[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
+illegal:/\n/})];const g={variants:[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
 },E={begin:"<",end:">",contains:[{beginKeywords:"in out"},a]
 },_=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",b={
 begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"],
@@ -1081,7 +1109,7 @@
 },contains:[{className:"string",begin:"'",end:"'"},{className:"attribute",
 begin:"^Content",end:":",excludeEnd:!0}]})})());
 hljs.registerLanguage("css",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
+;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
 ;return n=>{const a=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
 HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
 ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
@@ -1356,7 +1384,7 @@
 end:"$|;",illegal:/=/,contains:[n.inherit(n.TITLE_MODE,{
 begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|!)?"}),{begin:"<\\s*",contains:[{
 begin:"("+n.IDENT_RE+"::)?"+n.IDENT_RE,relevance:0}]}].concat(b)},{
-className:"function",begin:e(/def\s*/,(_=a+"\\s*(\\(|;|$)",e("(?=",_,")"))),
+className:"function",begin:e(/def\s+/,(_=a+"\\s*(\\(|;|$)",e("(?=",_,")"))),
 relevance:0,keywords:"def",end:"$|;",contains:[n.inherit(n.TITLE_MODE,{begin:a
 }),l].concat(b)},{begin:n.IDENT_RE+"::"},{className:"symbol",
 begin:n.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol",
@@ -1564,7 +1592,7 @@
 contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{
 className:"meta",begin:"#",end:"$"}]})})());
 hljs.registerLanguage("gml",(()=>{"use strict";return e=>({name:"GML",
-aliases:["GML"],case_insensitive:!1,keywords:{
+case_insensitive:!1,keywords:{
 keyword:"begin end if then else while do for break continue with until repeat exit and or xor not return mod div switch case default var globalvar enum function constructor delete #macro #region #endregion",
 built_in:"is_real is_string is_array is_undefined is_int32 is_int64 is_ptr is_vec3 is_vec4 is_matrix is_bool is_method is_struct is_infinity is_nan is_numeric typeof variable_global_exists variable_global_get variable_global_set variable_instance_exists variable_instance_get variable_instance_set variable_instance_get_names variable_struct_exists variable_struct_get variable_struct_get_names variable_struct_names_count variable_struct_remove variable_struct_set array_delete array_insert array_length array_length_1d array_length_2d array_height_2d array_equals array_create array_copy array_pop array_push array_resize array_sort random random_range irandom irandom_range random_set_seed random_get_seed randomize randomise choose abs round floor ceil sign frac sqrt sqr exp ln log2 log10 sin cos tan arcsin arccos arctan arctan2 dsin dcos dtan darcsin darccos darctan darctan2 degtorad radtodeg power logn min max mean median clamp lerp dot_product dot_product_3d dot_product_normalised dot_product_3d_normalised dot_product_normalized dot_product_3d_normalized math_set_epsilon math_get_epsilon angle_difference point_distance_3d point_distance point_direction lengthdir_x lengthdir_y real string int64 ptr string_format chr ansi_char ord string_length string_byte_length string_pos string_copy string_char_at string_ord_at string_byte_at string_set_byte_at string_delete string_insert string_lower string_upper string_repeat string_letters string_digits string_lettersdigits string_replace string_replace_all string_count string_hash_to_newline clipboard_has_text clipboard_set_text clipboard_get_text date_current_datetime date_create_datetime date_valid_datetime date_inc_year date_inc_month date_inc_week date_inc_day date_inc_hour date_inc_minute date_inc_second date_get_year date_get_month date_get_week date_get_day date_get_hour date_get_minute date_get_second date_get_weekday date_get_day_of_year date_get_hour_of_year date_get_minute_of_year date_get_second_of_year date_year_span date_month_span date_week_span date_day_span date_hour_span date_minute_span date_second_span date_compare_datetime date_compare_date date_compare_time date_date_of date_time_of date_datetime_string date_date_string date_time_string date_days_in_month date_days_in_year date_leap_year date_is_today date_set_timezone date_get_timezone game_set_speed game_get_speed motion_set motion_add place_free place_empty place_meeting place_snapped move_random move_snap move_towards_point move_contact_solid move_contact_all move_outside_solid move_outside_all move_bounce_solid move_bounce_all move_wrap distance_to_point distance_to_object position_empty position_meeting path_start path_end mp_linear_step mp_potential_step mp_linear_step_object mp_potential_step_object mp_potential_settings mp_linear_path mp_potential_path mp_linear_path_object mp_potential_path_object mp_grid_create mp_grid_destroy mp_grid_clear_all mp_grid_clear_cell mp_grid_clear_rectangle mp_grid_add_cell mp_grid_get_cell mp_grid_add_rectangle mp_grid_add_instances mp_grid_path mp_grid_draw mp_grid_to_ds_grid collision_point collision_rectangle collision_circle collision_ellipse collision_line collision_point_list collision_rectangle_list collision_circle_list collision_ellipse_list collision_line_list instance_position_list instance_place_list point_in_rectangle point_in_triangle point_in_circle rectangle_in_rectangle rectangle_in_triangle rectangle_in_circle instance_find instance_exists instance_number instance_position instance_nearest instance_furthest instance_place instance_create_depth instance_create_layer instance_copy instance_change instance_destroy position_destroy position_change instance_id_get instance_deactivate_all instance_deactivate_object instance_deactivate_region instance_activate_all instance_activate_object instance_activate_region room_goto room_goto_previous room_goto_next room_previous room_next room_restart game_end game_restart game_load game_save game_save_buffer game_load_buffer event_perform event_user event_perform_object event_inherited show_debug_message show_debug_overlay debug_event debug_get_callstack alarm_get alarm_set font_texture_page_size keyboard_set_map keyboard_get_map keyboard_unset_map keyboard_check keyboard_check_pressed keyboard_check_released keyboard_check_direct keyboard_get_numlock keyboard_set_numlock keyboard_key_press keyboard_key_release keyboard_clear io_clear mouse_check_button mouse_check_button_pressed mouse_check_button_released mouse_wheel_up mouse_wheel_down mouse_clear draw_self draw_sprite draw_sprite_pos draw_sprite_ext draw_sprite_stretched draw_sprite_stretched_ext draw_sprite_tiled draw_sprite_tiled_ext draw_sprite_part draw_sprite_part_ext draw_sprite_general draw_clear draw_clear_alpha draw_point draw_line draw_line_width draw_rectangle draw_roundrect draw_roundrect_ext draw_triangle draw_circle draw_ellipse draw_set_circle_precision draw_arrow draw_button draw_path draw_healthbar draw_getpixel draw_getpixel_ext draw_set_colour draw_set_color draw_set_alpha draw_get_colour draw_get_color draw_get_alpha merge_colour make_colour_rgb make_colour_hsv colour_get_red colour_get_green colour_get_blue colour_get_hue colour_get_saturation colour_get_value merge_color make_color_rgb make_color_hsv color_get_red color_get_green color_get_blue color_get_hue color_get_saturation color_get_value merge_color screen_save screen_save_part draw_set_font draw_set_halign draw_set_valign draw_text draw_text_ext string_width string_height string_width_ext string_height_ext draw_text_transformed draw_text_ext_transformed draw_text_colour draw_text_ext_colour draw_text_transformed_colour draw_text_ext_transformed_colour draw_text_color draw_text_ext_color draw_text_transformed_color draw_text_ext_transformed_color draw_point_colour draw_line_colour draw_line_width_colour draw_rectangle_colour draw_roundrect_colour draw_roundrect_colour_ext draw_triangle_colour draw_circle_colour draw_ellipse_colour draw_point_color draw_line_color draw_line_width_color draw_rectangle_color draw_roundrect_color draw_roundrect_color_ext draw_triangle_color draw_circle_color draw_ellipse_color draw_primitive_begin draw_vertex draw_vertex_colour draw_vertex_color draw_primitive_end sprite_get_uvs font_get_uvs sprite_get_texture font_get_texture texture_get_width texture_get_height texture_get_uvs draw_primitive_begin_texture draw_vertex_texture draw_vertex_texture_colour draw_vertex_texture_color texture_global_scale surface_create surface_create_ext surface_resize surface_free surface_exists surface_get_width surface_get_height surface_get_texture surface_set_target surface_set_target_ext surface_reset_target surface_depth_disable surface_get_depth_disable draw_surface draw_surface_stretched draw_surface_tiled draw_surface_part draw_surface_ext draw_surface_stretched_ext draw_surface_tiled_ext draw_surface_part_ext draw_surface_general surface_getpixel surface_getpixel_ext surface_save surface_save_part surface_copy surface_copy_part application_surface_draw_enable application_get_position application_surface_enable application_surface_is_enabled display_get_width display_get_height display_get_orientation display_get_gui_width display_get_gui_height display_reset display_mouse_get_x display_mouse_get_y display_mouse_set display_set_ui_visibility window_set_fullscreen window_get_fullscreen window_set_caption window_set_min_width window_set_max_width window_set_min_height window_set_max_height window_get_visible_rects window_get_caption window_set_cursor window_get_cursor window_set_colour window_get_colour window_set_color window_get_color window_set_position window_set_size window_set_rectangle window_center window_get_x window_get_y window_get_width window_get_height window_mouse_get_x window_mouse_get_y window_mouse_set window_view_mouse_get_x window_view_mouse_get_y window_views_mouse_get_x window_views_mouse_get_y audio_listener_position audio_listener_velocity audio_listener_orientation audio_emitter_position audio_emitter_create audio_emitter_free audio_emitter_exists audio_emitter_pitch audio_emitter_velocity audio_emitter_falloff audio_emitter_gain audio_play_sound audio_play_sound_on audio_play_sound_at audio_stop_sound audio_resume_music audio_music_is_playing audio_resume_sound audio_pause_sound audio_pause_music audio_channel_num audio_sound_length audio_get_type audio_falloff_set_model audio_play_music audio_stop_music audio_master_gain audio_music_gain audio_sound_gain audio_sound_pitch audio_stop_all audio_resume_all audio_pause_all audio_is_playing audio_is_paused audio_exists audio_sound_set_track_position audio_sound_get_track_position audio_emitter_get_gain audio_emitter_get_pitch audio_emitter_get_x audio_emitter_get_y audio_emitter_get_z audio_emitter_get_vx audio_emitter_get_vy audio_emitter_get_vz audio_listener_set_position audio_listener_set_velocity audio_listener_set_orientation audio_listener_get_data audio_set_master_gain audio_get_master_gain audio_sound_get_gain audio_sound_get_pitch audio_get_name audio_sound_set_track_position audio_sound_get_track_position audio_create_stream audio_destroy_stream audio_create_sync_group audio_destroy_sync_group audio_play_in_sync_group audio_start_sync_group audio_stop_sync_group audio_pause_sync_group audio_resume_sync_group audio_sync_group_get_track_pos audio_sync_group_debug audio_sync_group_is_playing audio_debug audio_group_load audio_group_unload audio_group_is_loaded audio_group_load_progress audio_group_name audio_group_stop_all audio_group_set_gain audio_create_buffer_sound audio_free_buffer_sound audio_create_play_queue audio_free_play_queue audio_queue_sound audio_get_recorder_count audio_get_recorder_info audio_start_recording audio_stop_recording audio_sound_get_listener_mask audio_emitter_get_listener_mask audio_get_listener_mask audio_sound_set_listener_mask audio_emitter_set_listener_mask audio_set_listener_mask audio_get_listener_count audio_get_listener_info audio_system show_message show_message_async clickable_add clickable_add_ext clickable_change clickable_change_ext clickable_delete clickable_exists clickable_set_style show_question show_question_async get_integer get_string get_integer_async get_string_async get_login_async get_open_filename get_save_filename get_open_filename_ext get_save_filename_ext show_error highscore_clear highscore_add highscore_value highscore_name draw_highscore sprite_exists sprite_get_name sprite_get_number sprite_get_width sprite_get_height sprite_get_xoffset sprite_get_yoffset sprite_get_bbox_left sprite_get_bbox_right sprite_get_bbox_top sprite_get_bbox_bottom sprite_save sprite_save_strip sprite_set_cache_size sprite_set_cache_size_ext sprite_get_tpe sprite_prefetch sprite_prefetch_multi sprite_flush sprite_flush_multi sprite_set_speed sprite_get_speed_type sprite_get_speed font_exists font_get_name font_get_fontname font_get_bold font_get_italic font_get_first font_get_last font_get_size font_set_cache_size path_exists path_get_name path_get_length path_get_time path_get_kind path_get_closed path_get_precision path_get_number path_get_point_x path_get_point_y path_get_point_speed path_get_x path_get_y path_get_speed script_exists script_get_name timeline_add timeline_delete timeline_clear timeline_exists timeline_get_name timeline_moment_clear timeline_moment_add_script timeline_size timeline_max_moment object_exists object_get_name object_get_sprite object_get_solid object_get_visible object_get_persistent object_get_mask object_get_parent object_get_physics object_is_ancestor room_exists room_get_name sprite_set_offset sprite_duplicate sprite_assign sprite_merge sprite_add sprite_replace sprite_create_from_surface sprite_add_from_surface sprite_delete sprite_set_alpha_from_sprite sprite_collision_mask font_add_enable_aa font_add_get_enable_aa font_add font_add_sprite font_add_sprite_ext font_replace font_replace_sprite font_replace_sprite_ext font_delete path_set_kind path_set_closed path_set_precision path_add path_assign path_duplicate path_append path_delete path_add_point path_insert_point path_change_point path_delete_point path_clear_points path_reverse path_mirror path_flip path_rotate path_rescale path_shift script_execute object_set_sprite object_set_solid object_set_visible object_set_persistent object_set_mask room_set_width room_set_height room_set_persistent room_set_background_colour room_set_background_color room_set_view room_set_viewport room_get_viewport room_set_view_enabled room_add room_duplicate room_assign room_instance_add room_instance_clear room_get_camera room_set_camera asset_get_index asset_get_type file_text_open_from_string file_text_open_read file_text_open_write file_text_open_append file_text_close file_text_write_string file_text_write_real file_text_writeln file_text_read_string file_text_read_real file_text_readln file_text_eof file_text_eoln file_exists file_delete file_rename file_copy directory_exists directory_create directory_destroy file_find_first file_find_next file_find_close file_attributes filename_name filename_path filename_dir filename_drive filename_ext filename_change_ext file_bin_open file_bin_rewrite file_bin_close file_bin_position file_bin_size file_bin_seek file_bin_write_byte file_bin_read_byte parameter_count parameter_string environment_get_variable ini_open_from_string ini_open ini_close ini_read_string ini_read_real ini_write_string ini_write_real ini_key_exists ini_section_exists ini_key_delete ini_section_delete ds_set_precision ds_exists ds_stack_create ds_stack_destroy ds_stack_clear ds_stack_copy ds_stack_size ds_stack_empty ds_stack_push ds_stack_pop ds_stack_top ds_stack_write ds_stack_read ds_queue_create ds_queue_destroy ds_queue_clear ds_queue_copy ds_queue_size ds_queue_empty ds_queue_enqueue ds_queue_dequeue ds_queue_head ds_queue_tail ds_queue_write ds_queue_read ds_list_create ds_list_destroy ds_list_clear ds_list_copy ds_list_size ds_list_empty ds_list_add ds_list_insert ds_list_replace ds_list_delete ds_list_find_index ds_list_find_value ds_list_mark_as_list ds_list_mark_as_map ds_list_sort ds_list_shuffle ds_list_write ds_list_read ds_list_set ds_map_create ds_map_destroy ds_map_clear ds_map_copy ds_map_size ds_map_empty ds_map_add ds_map_add_list ds_map_add_map ds_map_replace ds_map_replace_map ds_map_replace_list ds_map_delete ds_map_exists ds_map_find_value ds_map_find_previous ds_map_find_next ds_map_find_first ds_map_find_last ds_map_write ds_map_read ds_map_secure_save ds_map_secure_load ds_map_secure_load_buffer ds_map_secure_save_buffer ds_map_set ds_priority_create ds_priority_destroy ds_priority_clear ds_priority_copy ds_priority_size ds_priority_empty ds_priority_add ds_priority_change_priority ds_priority_find_priority ds_priority_delete_value ds_priority_delete_min ds_priority_find_min ds_priority_delete_max ds_priority_find_max ds_priority_write ds_priority_read ds_grid_create ds_grid_destroy ds_grid_copy ds_grid_resize ds_grid_width ds_grid_height ds_grid_clear ds_grid_set ds_grid_add ds_grid_multiply ds_grid_set_region ds_grid_add_region ds_grid_multiply_region ds_grid_set_disk ds_grid_add_disk ds_grid_multiply_disk ds_grid_set_grid_region ds_grid_add_grid_region ds_grid_multiply_grid_region ds_grid_get ds_grid_get_sum ds_grid_get_max ds_grid_get_min ds_grid_get_mean ds_grid_get_disk_sum ds_grid_get_disk_min ds_grid_get_disk_max ds_grid_get_disk_mean ds_grid_value_exists ds_grid_value_x ds_grid_value_y ds_grid_value_disk_exists ds_grid_value_disk_x ds_grid_value_disk_y ds_grid_shuffle ds_grid_write ds_grid_read ds_grid_sort ds_grid_set ds_grid_get effect_create_below effect_create_above effect_clear part_type_create part_type_destroy part_type_exists part_type_clear part_type_shape part_type_sprite part_type_size part_type_scale part_type_orientation part_type_life part_type_step part_type_death part_type_speed part_type_direction part_type_gravity part_type_colour1 part_type_colour2 part_type_colour3 part_type_colour_mix part_type_colour_rgb part_type_colour_hsv part_type_color1 part_type_color2 part_type_color3 part_type_color_mix part_type_color_rgb part_type_color_hsv part_type_alpha1 part_type_alpha2 part_type_alpha3 part_type_blend part_system_create part_system_create_layer part_system_destroy part_system_exists part_system_clear part_system_draw_order part_system_depth part_system_position part_system_automatic_update part_system_automatic_draw part_system_update part_system_drawit part_system_get_layer part_system_layer part_particles_create part_particles_create_colour part_particles_create_color part_particles_clear part_particles_count part_emitter_create part_emitter_destroy part_emitter_destroy_all part_emitter_exists part_emitter_clear part_emitter_region part_emitter_burst part_emitter_stream external_call external_define external_free window_handle window_device matrix_get matrix_set matrix_build_identity matrix_build matrix_build_lookat matrix_build_projection_ortho matrix_build_projection_perspective matrix_build_projection_perspective_fov matrix_multiply matrix_transform_vertex matrix_stack_push matrix_stack_pop matrix_stack_multiply matrix_stack_set matrix_stack_clear matrix_stack_top matrix_stack_is_empty browser_input_capture os_get_config os_get_info os_get_language os_get_region os_lock_orientation display_get_dpi_x display_get_dpi_y display_set_gui_size display_set_gui_maximise display_set_gui_maximize device_mouse_dbclick_enable display_set_timing_method display_get_timing_method display_set_sleep_margin display_get_sleep_margin virtual_key_add virtual_key_hide virtual_key_delete virtual_key_show draw_enable_drawevent draw_enable_swf_aa draw_set_swf_aa_level draw_get_swf_aa_level draw_texture_flush draw_flush gpu_set_blendenable gpu_set_ztestenable gpu_set_zfunc gpu_set_zwriteenable gpu_set_lightingenable gpu_set_fog gpu_set_cullmode gpu_set_blendmode gpu_set_blendmode_ext gpu_set_blendmode_ext_sepalpha gpu_set_colorwriteenable gpu_set_colourwriteenable gpu_set_alphatestenable gpu_set_alphatestref gpu_set_alphatestfunc gpu_set_texfilter gpu_set_texfilter_ext gpu_set_texrepeat gpu_set_texrepeat_ext gpu_set_tex_filter gpu_set_tex_filter_ext gpu_set_tex_repeat gpu_set_tex_repeat_ext gpu_set_tex_mip_filter gpu_set_tex_mip_filter_ext gpu_set_tex_mip_bias gpu_set_tex_mip_bias_ext gpu_set_tex_min_mip gpu_set_tex_min_mip_ext gpu_set_tex_max_mip gpu_set_tex_max_mip_ext gpu_set_tex_max_aniso gpu_set_tex_max_aniso_ext gpu_set_tex_mip_enable gpu_set_tex_mip_enable_ext gpu_get_blendenable gpu_get_ztestenable gpu_get_zfunc gpu_get_zwriteenable gpu_get_lightingenable gpu_get_fog gpu_get_cullmode gpu_get_blendmode gpu_get_blendmode_ext gpu_get_blendmode_ext_sepalpha gpu_get_blendmode_src gpu_get_blendmode_dest gpu_get_blendmode_srcalpha gpu_get_blendmode_destalpha gpu_get_colorwriteenable gpu_get_colourwriteenable gpu_get_alphatestenable gpu_get_alphatestref gpu_get_alphatestfunc gpu_get_texfilter gpu_get_texfilter_ext gpu_get_texrepeat gpu_get_texrepeat_ext gpu_get_tex_filter gpu_get_tex_filter_ext gpu_get_tex_repeat gpu_get_tex_repeat_ext gpu_get_tex_mip_filter gpu_get_tex_mip_filter_ext gpu_get_tex_mip_bias gpu_get_tex_mip_bias_ext gpu_get_tex_min_mip gpu_get_tex_min_mip_ext gpu_get_tex_max_mip gpu_get_tex_max_mip_ext gpu_get_tex_max_aniso gpu_get_tex_max_aniso_ext gpu_get_tex_mip_enable gpu_get_tex_mip_enable_ext gpu_push_state gpu_pop_state gpu_get_state gpu_set_state draw_light_define_ambient draw_light_define_direction draw_light_define_point draw_light_enable draw_set_lighting draw_light_get_ambient draw_light_get draw_get_lighting shop_leave_rating url_get_domain url_open url_open_ext url_open_full get_timer achievement_login achievement_logout achievement_post achievement_increment achievement_post_score achievement_available achievement_show_achievements achievement_show_leaderboards achievement_load_friends achievement_load_leaderboard achievement_send_challenge achievement_load_progress achievement_reset achievement_login_status achievement_get_pic achievement_show_challenge_notifications achievement_get_challenges achievement_event achievement_show achievement_get_info cloud_file_save cloud_string_save cloud_synchronise ads_enable ads_disable ads_setup ads_engagement_launch ads_engagement_available ads_engagement_active ads_event ads_event_preload ads_set_reward_callback ads_get_display_height ads_get_display_width ads_move ads_interstitial_available ads_interstitial_display device_get_tilt_x device_get_tilt_y device_get_tilt_z device_is_keypad_open device_mouse_check_button device_mouse_check_button_pressed device_mouse_check_button_released device_mouse_x device_mouse_y device_mouse_raw_x device_mouse_raw_y device_mouse_x_to_gui device_mouse_y_to_gui iap_activate iap_status iap_enumerate_products iap_restore_all iap_acquire iap_consume iap_product_details iap_purchase_details facebook_init facebook_login facebook_status facebook_graph_request facebook_dialog facebook_logout facebook_launch_offerwall facebook_post_message facebook_send_invite facebook_user_id facebook_accesstoken facebook_check_permission facebook_request_read_permissions facebook_request_publish_permissions gamepad_is_supported gamepad_get_device_count gamepad_is_connected gamepad_get_description gamepad_get_button_threshold gamepad_set_button_threshold gamepad_get_axis_deadzone gamepad_set_axis_deadzone gamepad_button_count gamepad_button_check gamepad_button_check_pressed gamepad_button_check_released gamepad_button_value gamepad_axis_count gamepad_axis_value gamepad_set_vibration gamepad_set_colour gamepad_set_color os_is_paused window_has_focus code_is_compiled http_get http_get_file http_post_string http_request json_encode json_decode zip_unzip load_csv base64_encode base64_decode md5_string_unicode md5_string_utf8 md5_file os_is_network_connected sha1_string_unicode sha1_string_utf8 sha1_file os_powersave_enable analytics_event analytics_event_ext win8_livetile_tile_notification win8_livetile_tile_clear win8_livetile_badge_notification win8_livetile_badge_clear win8_livetile_queue_enable win8_secondarytile_pin win8_secondarytile_badge_notification win8_secondarytile_delete win8_livetile_notification_begin win8_livetile_notification_secondary_begin win8_livetile_notification_expiry win8_livetile_notification_tag win8_livetile_notification_text_add win8_livetile_notification_image_add win8_livetile_notification_end win8_appbar_enable win8_appbar_add_element win8_appbar_remove_element win8_settingscharm_add_entry win8_settingscharm_add_html_entry win8_settingscharm_add_xaml_entry win8_settingscharm_set_xaml_property win8_settingscharm_get_xaml_property win8_settingscharm_remove_entry win8_share_image win8_share_screenshot win8_share_file win8_share_url win8_share_text win8_search_enable win8_search_disable win8_search_add_suggestions win8_device_touchscreen_available win8_license_initialize_sandbox win8_license_trial_version winphone_license_trial_version winphone_tile_title winphone_tile_count winphone_tile_back_title winphone_tile_back_content winphone_tile_back_content_wide winphone_tile_front_image winphone_tile_front_image_small winphone_tile_front_image_wide winphone_tile_back_image winphone_tile_back_image_wide winphone_tile_background_colour winphone_tile_background_color winphone_tile_icon_image winphone_tile_small_icon_image winphone_tile_wide_content winphone_tile_cycle_images winphone_tile_small_background_image physics_world_create physics_world_gravity physics_world_update_speed physics_world_update_iterations physics_world_draw_debug physics_pause_enable physics_fixture_create physics_fixture_set_kinematic physics_fixture_set_density physics_fixture_set_awake physics_fixture_set_restitution physics_fixture_set_friction physics_fixture_set_collision_group physics_fixture_set_sensor physics_fixture_set_linear_damping physics_fixture_set_angular_damping physics_fixture_set_circle_shape physics_fixture_set_box_shape physics_fixture_set_edge_shape physics_fixture_set_polygon_shape physics_fixture_set_chain_shape physics_fixture_add_point physics_fixture_bind physics_fixture_bind_ext physics_fixture_delete physics_apply_force physics_apply_impulse physics_apply_angular_impulse physics_apply_local_force physics_apply_local_impulse physics_apply_torque physics_mass_properties physics_draw_debug physics_test_overlap physics_remove_fixture physics_set_friction physics_set_density physics_set_restitution physics_get_friction physics_get_density physics_get_restitution physics_joint_distance_create physics_joint_rope_create physics_joint_revolute_create physics_joint_prismatic_create physics_joint_pulley_create physics_joint_wheel_create physics_joint_weld_create physics_joint_friction_create physics_joint_gear_create physics_joint_enable_motor physics_joint_get_value physics_joint_set_value physics_joint_delete physics_particle_create physics_particle_delete physics_particle_delete_region_circle physics_particle_delete_region_box physics_particle_delete_region_poly physics_particle_set_flags physics_particle_set_category_flags physics_particle_draw physics_particle_draw_ext physics_particle_count physics_particle_get_data physics_particle_get_data_particle physics_particle_group_begin physics_particle_group_circle physics_particle_group_box physics_particle_group_polygon physics_particle_group_add_point physics_particle_group_end physics_particle_group_join physics_particle_group_delete physics_particle_group_count physics_particle_group_get_data physics_particle_group_get_mass physics_particle_group_get_inertia physics_particle_group_get_centre_x physics_particle_group_get_centre_y physics_particle_group_get_vel_x physics_particle_group_get_vel_y physics_particle_group_get_ang_vel physics_particle_group_get_x physics_particle_group_get_y physics_particle_group_get_angle physics_particle_set_group_flags physics_particle_get_group_flags physics_particle_get_max_count physics_particle_get_radius physics_particle_get_density physics_particle_get_damping physics_particle_get_gravity_scale physics_particle_set_max_count physics_particle_set_radius physics_particle_set_density physics_particle_set_damping physics_particle_set_gravity_scale network_create_socket network_create_socket_ext network_create_server network_create_server_raw network_connect network_connect_raw network_send_packet network_send_raw network_send_broadcast network_send_udp network_send_udp_raw network_set_timeout network_set_config network_resolve network_destroy buffer_create buffer_write buffer_read buffer_seek buffer_get_surface buffer_set_surface buffer_delete buffer_exists buffer_get_type buffer_get_alignment buffer_poke buffer_peek buffer_save buffer_save_ext buffer_load buffer_load_ext buffer_load_partial buffer_copy buffer_fill buffer_get_size buffer_tell buffer_resize buffer_md5 buffer_sha1 buffer_base64_encode buffer_base64_decode buffer_base64_decode_ext buffer_sizeof buffer_get_address buffer_create_from_vertex_buffer buffer_create_from_vertex_buffer_ext buffer_copy_from_vertex_buffer buffer_async_group_begin buffer_async_group_option buffer_async_group_end buffer_load_async buffer_save_async gml_release_mode gml_pragma steam_activate_overlay steam_is_overlay_enabled steam_is_overlay_activated steam_get_persona_name steam_initialised steam_is_cloud_enabled_for_app steam_is_cloud_enabled_for_account steam_file_persisted steam_get_quota_total steam_get_quota_free steam_file_write steam_file_write_file steam_file_read steam_file_delete steam_file_exists steam_file_size steam_file_share steam_is_screenshot_requested steam_send_screenshot steam_is_user_logged_on steam_get_user_steam_id steam_user_owns_dlc steam_user_installed_dlc steam_set_achievement steam_get_achievement steam_clear_achievement steam_set_stat_int steam_set_stat_float steam_set_stat_avg_rate steam_get_stat_int steam_get_stat_float steam_get_stat_avg_rate steam_reset_all_stats steam_reset_all_stats_achievements steam_stats_ready steam_create_leaderboard steam_upload_score steam_upload_score_ext steam_download_scores_around_user steam_download_scores steam_download_friends_scores steam_upload_score_buffer steam_upload_score_buffer_ext steam_current_game_language steam_available_languages steam_activate_overlay_browser steam_activate_overlay_user steam_activate_overlay_store steam_get_user_persona_name steam_get_app_id steam_get_user_account_id steam_ugc_download steam_ugc_create_item steam_ugc_start_item_update steam_ugc_set_item_title steam_ugc_set_item_description steam_ugc_set_item_visibility steam_ugc_set_item_tags steam_ugc_set_item_content steam_ugc_set_item_preview steam_ugc_submit_item_update steam_ugc_get_item_update_progress steam_ugc_subscribe_item steam_ugc_unsubscribe_item steam_ugc_num_subscribed_items steam_ugc_get_subscribed_items steam_ugc_get_item_install_info steam_ugc_get_item_update_info steam_ugc_request_item_details steam_ugc_create_query_user steam_ugc_create_query_user_ex steam_ugc_create_query_all steam_ugc_create_query_all_ex steam_ugc_query_set_cloud_filename_filter steam_ugc_query_set_match_any_tag steam_ugc_query_set_search_text steam_ugc_query_set_ranked_by_trend_days steam_ugc_query_add_required_tag steam_ugc_query_add_excluded_tag steam_ugc_query_set_return_long_description steam_ugc_query_set_return_total_only steam_ugc_query_set_allow_cached_response steam_ugc_send_query shader_set shader_get_name shader_reset shader_current shader_is_compiled shader_get_sampler_index shader_get_uniform shader_set_uniform_i shader_set_uniform_i_array shader_set_uniform_f shader_set_uniform_f_array shader_set_uniform_matrix shader_set_uniform_matrix_array shader_enable_corner_id texture_set_stage texture_get_texel_width texture_get_texel_height shaders_are_supported vertex_format_begin vertex_format_end vertex_format_delete vertex_format_add_position vertex_format_add_position_3d vertex_format_add_colour vertex_format_add_color vertex_format_add_normal vertex_format_add_texcoord vertex_format_add_textcoord vertex_format_add_custom vertex_create_buffer vertex_create_buffer_ext vertex_delete_buffer vertex_begin vertex_end vertex_position vertex_position_3d vertex_colour vertex_color vertex_argb vertex_texcoord vertex_normal vertex_float1 vertex_float2 vertex_float3 vertex_float4 vertex_ubyte4 vertex_submit vertex_freeze vertex_get_number vertex_get_buffer_size vertex_create_buffer_from_buffer vertex_create_buffer_from_buffer_ext push_local_notification push_get_first_local_notification push_get_next_local_notification push_cancel_local_notification skeleton_animation_set skeleton_animation_get skeleton_animation_mix skeleton_animation_set_ext skeleton_animation_get_ext skeleton_animation_get_duration skeleton_animation_get_frames skeleton_animation_clear skeleton_skin_set skeleton_skin_get skeleton_attachment_set skeleton_attachment_get skeleton_attachment_create skeleton_collision_draw_set skeleton_bone_data_get skeleton_bone_data_set skeleton_bone_state_get skeleton_bone_state_set skeleton_get_minmax skeleton_get_num_bounds skeleton_get_bounds skeleton_animation_get_frame skeleton_animation_set_frame draw_skeleton draw_skeleton_time draw_skeleton_instance draw_skeleton_collision skeleton_animation_list skeleton_skin_list skeleton_slot_data layer_get_id layer_get_id_at_depth layer_get_depth layer_create layer_destroy layer_destroy_instances layer_add_instance layer_has_instance layer_set_visible layer_get_visible layer_exists layer_x layer_y layer_get_x layer_get_y layer_hspeed layer_vspeed layer_get_hspeed layer_get_vspeed layer_script_begin layer_script_end layer_shader layer_get_script_begin layer_get_script_end layer_get_shader layer_set_target_room layer_get_target_room layer_reset_target_room layer_get_all layer_get_all_elements layer_get_name layer_depth layer_get_element_layer layer_get_element_type layer_element_move layer_force_draw_depth layer_is_draw_depth_forced layer_get_forced_depth layer_background_get_id layer_background_exists layer_background_create layer_background_destroy layer_background_visible layer_background_change layer_background_sprite layer_background_htiled layer_background_vtiled layer_background_stretch layer_background_yscale layer_background_xscale layer_background_blend layer_background_alpha layer_background_index layer_background_speed layer_background_get_visible layer_background_get_sprite layer_background_get_htiled layer_background_get_vtiled layer_background_get_stretch layer_background_get_yscale layer_background_get_xscale layer_background_get_blend layer_background_get_alpha layer_background_get_index layer_background_get_speed layer_sprite_get_id layer_sprite_exists layer_sprite_create layer_sprite_destroy layer_sprite_change layer_sprite_index layer_sprite_speed layer_sprite_xscale layer_sprite_yscale layer_sprite_angle layer_sprite_blend layer_sprite_alpha layer_sprite_x layer_sprite_y layer_sprite_get_sprite layer_sprite_get_index layer_sprite_get_speed layer_sprite_get_xscale layer_sprite_get_yscale layer_sprite_get_angle layer_sprite_get_blend layer_sprite_get_alpha layer_sprite_get_x layer_sprite_get_y layer_tilemap_get_id layer_tilemap_exists layer_tilemap_create layer_tilemap_destroy tilemap_tileset tilemap_x tilemap_y tilemap_set tilemap_set_at_pixel tilemap_get_tileset tilemap_get_tile_width tilemap_get_tile_height tilemap_get_width tilemap_get_height tilemap_get_x tilemap_get_y tilemap_get tilemap_get_at_pixel tilemap_get_cell_x_at_pixel tilemap_get_cell_y_at_pixel tilemap_clear draw_tilemap draw_tile tilemap_set_global_mask tilemap_get_global_mask tilemap_set_mask tilemap_get_mask tilemap_get_frame tile_set_empty tile_set_index tile_set_flip tile_set_mirror tile_set_rotate tile_get_empty tile_get_index tile_get_flip tile_get_mirror tile_get_rotate layer_tile_exists layer_tile_create layer_tile_destroy layer_tile_change layer_tile_xscale layer_tile_yscale layer_tile_blend layer_tile_alpha layer_tile_x layer_tile_y layer_tile_region layer_tile_visible layer_tile_get_sprite layer_tile_get_xscale layer_tile_get_yscale layer_tile_get_blend layer_tile_get_alpha layer_tile_get_x layer_tile_get_y layer_tile_get_region layer_tile_get_visible layer_instance_get_instance instance_activate_layer instance_deactivate_layer camera_create camera_create_view camera_destroy camera_apply camera_get_active camera_get_default camera_set_default camera_set_view_mat camera_set_proj_mat camera_set_update_script camera_set_begin_script camera_set_end_script camera_set_view_pos camera_set_view_size camera_set_view_speed camera_set_view_border camera_set_view_angle camera_set_view_target camera_get_view_mat camera_get_proj_mat camera_get_update_script camera_get_begin_script camera_get_end_script camera_get_view_x camera_get_view_y camera_get_view_width camera_get_view_height camera_get_view_speed_x camera_get_view_speed_y camera_get_view_border_x camera_get_view_border_y camera_get_view_angle camera_get_view_target view_get_camera view_get_visible view_get_xport view_get_yport view_get_wport view_get_hport view_get_surface_id view_set_camera view_set_visible view_set_xport view_set_yport view_set_wport view_set_hport view_set_surface_id gesture_drag_time gesture_drag_distance gesture_flick_speed gesture_double_tap_time gesture_double_tap_distance gesture_pinch_distance gesture_pinch_angle_towards gesture_pinch_angle_away gesture_rotate_time gesture_rotate_angle gesture_tap_count gesture_get_drag_time gesture_get_drag_distance gesture_get_flick_speed gesture_get_double_tap_time gesture_get_double_tap_distance gesture_get_pinch_distance gesture_get_pinch_angle_towards gesture_get_pinch_angle_away gesture_get_rotate_time gesture_get_rotate_angle gesture_get_tap_count keyboard_virtual_show keyboard_virtual_hide keyboard_virtual_status keyboard_virtual_height",
 literal:"self other all noone global local undefined pointer_invalid pointer_null path_action_stop path_action_restart path_action_continue path_action_reverse true false pi GM_build_date GM_version GM_runtime_version  timezone_local timezone_utc gamespeed_fps gamespeed_microseconds  ev_create ev_destroy ev_step ev_alarm ev_keyboard ev_mouse ev_collision ev_other ev_draw ev_draw_begin ev_draw_end ev_draw_pre ev_draw_post ev_keypress ev_keyrelease ev_trigger ev_left_button ev_right_button ev_middle_button ev_no_button ev_left_press ev_right_press ev_middle_press ev_left_release ev_right_release ev_middle_release ev_mouse_enter ev_mouse_leave ev_mouse_wheel_up ev_mouse_wheel_down ev_global_left_button ev_global_right_button ev_global_middle_button ev_global_left_press ev_global_right_press ev_global_middle_press ev_global_left_release ev_global_right_release ev_global_middle_release ev_joystick1_left ev_joystick1_right ev_joystick1_up ev_joystick1_down ev_joystick1_button1 ev_joystick1_button2 ev_joystick1_button3 ev_joystick1_button4 ev_joystick1_button5 ev_joystick1_button6 ev_joystick1_button7 ev_joystick1_button8 ev_joystick2_left ev_joystick2_right ev_joystick2_up ev_joystick2_down ev_joystick2_button1 ev_joystick2_button2 ev_joystick2_button3 ev_joystick2_button4 ev_joystick2_button5 ev_joystick2_button6 ev_joystick2_button7 ev_joystick2_button8 ev_outside ev_boundary ev_game_start ev_game_end ev_room_start ev_room_end ev_no_more_lives ev_animation_end ev_end_of_path ev_no_more_health ev_close_button ev_user0 ev_user1 ev_user2 ev_user3 ev_user4 ev_user5 ev_user6 ev_user7 ev_user8 ev_user9 ev_user10 ev_user11 ev_user12 ev_user13 ev_user14 ev_user15 ev_step_normal ev_step_begin ev_step_end ev_gui ev_gui_begin ev_gui_end ev_cleanup ev_gesture ev_gesture_tap ev_gesture_double_tap ev_gesture_drag_start ev_gesture_dragging ev_gesture_drag_end ev_gesture_flick ev_gesture_pinch_start ev_gesture_pinch_in ev_gesture_pinch_out ev_gesture_pinch_end ev_gesture_rotate_start ev_gesture_rotating ev_gesture_rotate_end ev_global_gesture_tap ev_global_gesture_double_tap ev_global_gesture_drag_start ev_global_gesture_dragging ev_global_gesture_drag_end ev_global_gesture_flick ev_global_gesture_pinch_start ev_global_gesture_pinch_in ev_global_gesture_pinch_out ev_global_gesture_pinch_end ev_global_gesture_rotate_start ev_global_gesture_rotating ev_global_gesture_rotate_end vk_nokey vk_anykey vk_enter vk_return vk_shift vk_control vk_alt vk_escape vk_space vk_backspace vk_tab vk_pause vk_printscreen vk_left vk_right vk_up vk_down vk_home vk_end vk_delete vk_insert vk_pageup vk_pagedown vk_f1 vk_f2 vk_f3 vk_f4 vk_f5 vk_f6 vk_f7 vk_f8 vk_f9 vk_f10 vk_f11 vk_f12 vk_numpad0 vk_numpad1 vk_numpad2 vk_numpad3 vk_numpad4 vk_numpad5 vk_numpad6 vk_numpad7 vk_numpad8 vk_numpad9 vk_divide vk_multiply vk_subtract vk_add vk_decimal vk_lshift vk_lcontrol vk_lalt vk_rshift vk_rcontrol vk_ralt  mb_any mb_none mb_left mb_right mb_middle c_aqua c_black c_blue c_dkgray c_fuchsia c_gray c_green c_lime c_ltgray c_maroon c_navy c_olive c_purple c_red c_silver c_teal c_white c_yellow c_orange fa_left fa_center fa_right fa_top fa_middle fa_bottom pr_pointlist pr_linelist pr_linestrip pr_trianglelist pr_trianglestrip pr_trianglefan bm_complex bm_normal bm_add bm_max bm_subtract bm_zero bm_one bm_src_colour bm_inv_src_colour bm_src_color bm_inv_src_color bm_src_alpha bm_inv_src_alpha bm_dest_alpha bm_inv_dest_alpha bm_dest_colour bm_inv_dest_colour bm_dest_color bm_inv_dest_color bm_src_alpha_sat tf_point tf_linear tf_anisotropic mip_off mip_on mip_markedonly audio_falloff_none audio_falloff_inverse_distance audio_falloff_inverse_distance_clamped audio_falloff_linear_distance audio_falloff_linear_distance_clamped audio_falloff_exponent_distance audio_falloff_exponent_distance_clamped audio_old_system audio_new_system audio_mono audio_stereo audio_3d cr_default cr_none cr_arrow cr_cross cr_beam cr_size_nesw cr_size_ns cr_size_nwse cr_size_we cr_uparrow cr_hourglass cr_drag cr_appstart cr_handpoint cr_size_all spritespeed_framespersecond spritespeed_framespergameframe asset_object asset_unknown asset_sprite asset_sound asset_room asset_path asset_script asset_font asset_timeline asset_tiles asset_shader fa_readonly fa_hidden fa_sysfile fa_volumeid fa_directory fa_archive  ds_type_map ds_type_list ds_type_stack ds_type_queue ds_type_grid ds_type_priority ef_explosion ef_ring ef_ellipse ef_firework ef_smoke ef_smokeup ef_star ef_spark ef_flare ef_cloud ef_rain ef_snow pt_shape_pixel pt_shape_disk pt_shape_square pt_shape_line pt_shape_star pt_shape_circle pt_shape_ring pt_shape_sphere pt_shape_flare pt_shape_spark pt_shape_explosion pt_shape_cloud pt_shape_smoke pt_shape_snow ps_distr_linear ps_distr_gaussian ps_distr_invgaussian ps_shape_rectangle ps_shape_ellipse ps_shape_diamond ps_shape_line ty_real ty_string dll_cdecl dll_stdcall matrix_view matrix_projection matrix_world os_win32 os_windows os_macosx os_ios os_android os_symbian os_linux os_unknown os_winphone os_tizen os_win8native os_wiiu os_3ds  os_psvita os_bb10 os_ps4 os_xboxone os_ps3 os_xbox360 os_uwp os_tvos os_switch browser_not_a_browser browser_unknown browser_ie browser_firefox browser_chrome browser_safari browser_safari_mobile browser_opera browser_tizen browser_edge browser_windows_store browser_ie_mobile  device_ios_unknown device_ios_iphone device_ios_iphone_retina device_ios_ipad device_ios_ipad_retina device_ios_iphone5 device_ios_iphone6 device_ios_iphone6plus device_emulator device_tablet display_landscape display_landscape_flipped display_portrait display_portrait_flipped tm_sleep tm_countvsyncs of_challenge_win of_challen ge_lose of_challenge_tie leaderboard_type_number leaderboard_type_time_mins_secs cmpfunc_never cmpfunc_less cmpfunc_equal cmpfunc_lessequal cmpfunc_greater cmpfunc_notequal cmpfunc_greaterequal cmpfunc_always cull_noculling cull_clockwise cull_counterclockwise lighttype_dir lighttype_point iap_ev_storeload iap_ev_product iap_ev_purchase iap_ev_consume iap_ev_restore iap_storeload_ok iap_storeload_failed iap_status_uninitialised iap_status_unavailable iap_status_loading iap_status_available iap_status_processing iap_status_restoring iap_failed iap_unavailable iap_available iap_purchased iap_canceled iap_refunded fb_login_default fb_login_fallback_to_webview fb_login_no_fallback_to_webview fb_login_forcing_webview fb_login_use_system_account fb_login_forcing_safari  phy_joint_anchor_1_x phy_joint_anchor_1_y phy_joint_anchor_2_x phy_joint_anchor_2_y phy_joint_reaction_force_x phy_joint_reaction_force_y phy_joint_reaction_torque phy_joint_motor_speed phy_joint_angle phy_joint_motor_torque phy_joint_max_motor_torque phy_joint_translation phy_joint_speed phy_joint_motor_force phy_joint_max_motor_force phy_joint_length_1 phy_joint_length_2 phy_joint_damping_ratio phy_joint_frequency phy_joint_lower_angle_limit phy_joint_upper_angle_limit phy_joint_angle_limits phy_joint_max_length phy_joint_max_torque phy_joint_max_force phy_debug_render_aabb phy_debug_render_collision_pairs phy_debug_render_coms phy_debug_render_core_shapes phy_debug_render_joints phy_debug_render_obb phy_debug_render_shapes  phy_particle_flag_water phy_particle_flag_zombie phy_particle_flag_wall phy_particle_flag_spring phy_particle_flag_elastic phy_particle_flag_viscous phy_particle_flag_powder phy_particle_flag_tensile phy_particle_flag_colourmixing phy_particle_flag_colormixing phy_particle_group_flag_solid phy_particle_group_flag_rigid phy_particle_data_flag_typeflags phy_particle_data_flag_position phy_particle_data_flag_velocity phy_particle_data_flag_colour phy_particle_data_flag_color phy_particle_data_flag_category  achievement_our_info achievement_friends_info achievement_leaderboard_info achievement_achievement_info achievement_filter_all_players achievement_filter_friends_only achievement_filter_favorites_only achievement_type_achievement_challenge achievement_type_score_challenge achievement_pic_loaded  achievement_show_ui achievement_show_profile achievement_show_leaderboard achievement_show_achievement achievement_show_bank achievement_show_friend_picker achievement_show_purchase_prompt network_socket_tcp network_socket_udp network_socket_bluetooth network_type_connect network_type_disconnect network_type_data network_type_non_blocking_connect network_config_connect_timeout network_config_use_non_blocking_socket network_config_enable_reliable_udp network_config_disable_reliable_udp buffer_fixed buffer_grow buffer_wrap buffer_fast buffer_vbuffer buffer_network buffer_u8 buffer_s8 buffer_u16 buffer_s16 buffer_u32 buffer_s32 buffer_u64 buffer_f16 buffer_f32 buffer_f64 buffer_bool buffer_text buffer_string buffer_surface_copy buffer_seek_start buffer_seek_relative buffer_seek_end buffer_generalerror buffer_outofspace buffer_outofbounds buffer_invalidtype  text_type button_type input_type ANSI_CHARSET DEFAULT_CHARSET EASTEUROPE_CHARSET RUSSIAN_CHARSET SYMBOL_CHARSET SHIFTJIS_CHARSET HANGEUL_CHARSET GB2312_CHARSET CHINESEBIG5_CHARSET JOHAB_CHARSET HEBREW_CHARSET ARABIC_CHARSET GREEK_CHARSET TURKISH_CHARSET VIETNAMESE_CHARSET THAI_CHARSET MAC_CHARSET BALTIC_CHARSET OEM_CHARSET  gp_face1 gp_face2 gp_face3 gp_face4 gp_shoulderl gp_shoulderr gp_shoulderlb gp_shoulderrb gp_select gp_start gp_stickl gp_stickr gp_padu gp_padd gp_padl gp_padr gp_axislh gp_axislv gp_axisrh gp_axisrv ov_friends ov_community ov_players ov_settings ov_gamegroup ov_achievements lb_sort_none lb_sort_ascending lb_sort_descending lb_disp_none lb_disp_numeric lb_disp_time_sec lb_disp_time_ms ugc_result_success ugc_filetype_community ugc_filetype_microtrans ugc_visibility_public ugc_visibility_friends_only ugc_visibility_private ugc_query_RankedByVote ugc_query_RankedByPublicationDate ugc_query_AcceptedForGameRankedByAcceptanceDate ugc_query_RankedByTrend ugc_query_FavoritedByFriendsRankedByPublicationDate ugc_query_CreatedByFriendsRankedByPublicationDate ugc_query_RankedByNumTimesReported ugc_query_CreatedByFollowedUsersRankedByPublicationDate ugc_query_NotYetRated ugc_query_RankedByTotalVotesAsc ugc_query_RankedByVotesUp ugc_query_RankedByTextSearch ugc_sortorder_CreationOrderDesc ugc_sortorder_CreationOrderAsc ugc_sortorder_TitleAsc ugc_sortorder_LastUpdatedDesc ugc_sortorder_SubscriptionDateDesc ugc_sortorder_VoteScoreDesc ugc_sortorder_ForModeration ugc_list_Published ugc_list_VotedOn ugc_list_VotedUp ugc_list_VotedDown ugc_list_WillVoteLater ugc_list_Favorited ugc_list_Subscribed ugc_list_UsedOrPlayed ugc_list_Followed ugc_match_Items ugc_match_Items_Mtx ugc_match_Items_ReadyToUse ugc_match_Collections ugc_match_Artwork ugc_match_Videos ugc_match_Screenshots ugc_match_AllGuides ugc_match_WebGuides ugc_match_IntegratedGuides ugc_match_UsableInGame ugc_match_ControllerBindings  vertex_usage_position vertex_usage_colour vertex_usage_color vertex_usage_normal vertex_usage_texcoord vertex_usage_textcoord vertex_usage_blendweight vertex_usage_blendindices vertex_usage_psize vertex_usage_tangent vertex_usage_binormal vertex_usage_fog vertex_usage_depth vertex_usage_sample vertex_type_float1 vertex_type_float2 vertex_type_float3 vertex_type_float4 vertex_type_colour vertex_type_color vertex_type_ubyte4 layerelementtype_undefined layerelementtype_background layerelementtype_instance layerelementtype_oldtilemap layerelementtype_sprite layerelementtype_tilemap layerelementtype_particlesystem layerelementtype_tile tile_rotate tile_flip tile_mirror tile_index_mask kbv_type_default kbv_type_ascii kbv_type_url kbv_type_email kbv_type_numbers kbv_type_phone kbv_type_phone_name kbv_returnkey_default kbv_returnkey_go kbv_returnkey_google kbv_returnkey_join kbv_returnkey_next kbv_returnkey_route kbv_returnkey_search kbv_returnkey_send kbv_returnkey_yahoo kbv_returnkey_done kbv_returnkey_continue kbv_returnkey_emergency kbv_autocapitalize_none kbv_autocapitalize_words kbv_autocapitalize_sentences kbv_autocapitalize_characters",
@@ -1752,17 +1780,17 @@
 t}})());
 hljs.registerLanguage("http",(()=>{"use strict";function e(...e){
 return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a="HTTP/(2|1\\.[01])",s=[{className:"attribute",
+})).join("")}return n=>{const a="HTTP/(2|1\\.[01])",s={className:"attribute",
 begin:e("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{
 className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]}
-},{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{
+},t=[s,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{
 name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+a+" \\d{3})",
 end:/$/,contains:[{className:"meta",begin:a},{className:"number",
-begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:s}},{
+begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:t}},{
 begin:"(?=^[A-Z]+ (.*?) "+a+"$)",end:/$/,contains:[{className:"string",
 begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:a},{
-className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:s}
-}]}}})());
+className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:t}
+},n.inherit(s,{relevance:0})]}}})());
 hljs.registerLanguage("hy",(()=>{"use strict";return e=>{
 var a="a-zA-Z_\\-!.?+*=<>&#'",t="["+a+"]["+a+"0-9/;:]*",i={$pattern:t,
 "builtin-name":"!= % %= & &= * ** **= *= *map + += , --build-class-- --import-- -= . / // //= /= < << <<= <= = > >= >> >>= @ @= ^ ^= abs accumulate all and any ap-compose ap-dotimes ap-each ap-each-while ap-filter ap-first ap-if ap-last ap-map ap-map-when ap-pipe ap-reduce ap-reject apply as-> ascii assert assoc bin break butlast callable calling-module-name car case cdr chain chr coll? combinations compile compress cond cons cons? continue count curry cut cycle dec def default-method defclass defmacro defmacro-alias defmacro/g! defmain defmethod defmulti defn defn-alias defnc defnr defreader defseq del delattr delete-route dict-comp dir disassemble dispatch-reader-macro distinct divmod do doto drop drop-last drop-while empty? end-sequence eval eval-and-compile eval-when-compile even? every? except exec filter first flatten float? fn fnc fnr for for* format fraction genexpr gensym get getattr global globals group-by hasattr hash hex id identity if if* if-not if-python2 import in inc input instance? integer integer-char? integer? interleave interpose is is-coll is-cons is-empty is-even is-every is-float is-instance is-integer is-integer-char is-iterable is-iterator is-keyword is-neg is-none is-not is-numeric is-odd is-pos is-string is-symbol is-zero isinstance islice issubclass iter iterable? iterate iterator? keyword keyword? lambda last len let lif lif-not list* list-comp locals loop macro-error macroexpand macroexpand-1 macroexpand-all map max merge-with method-decorator min multi-decorator multicombinations name neg? next none? nonlocal not not-in not? nth numeric? oct odd? open or ord partition permutations pos? post-route postwalk pow prewalk print product profile/calls profile/cpu put-route quasiquote quote raise range read read-str recursive-replace reduce remove repeat repeatedly repr require rest round route route-with-methods rwm second seq set-comp setattr setv some sorted string string? sum switch symbol? take take-nth take-while tee try unless unquote unquote-splicing vars walk when while with with* with-decorator with-gensyms xi xor yield yield-from zero? zip zip-longest | |= ~"
@@ -2071,7 +2099,7 @@
 className:"string",end:"(?=\\\\end\\{"+e+"\\})"}),p=(e="string")=>({relevance:0,
 begin:/\{/,starts:{endsParent:!0,contains:[{className:e,end:/(?=\})/,
 endsParent:!0,contains:[{begin:/\{/,end:/\}/,relevance:0,contains:["self"]}]}]}
-});return{name:"LaTeX",aliases:["TeX"],
+});return{name:"LaTeX",aliases:["tex"],
 contains:[...["verb","lstinline"].map((e=>d(e,{contains:[m()]}))),d("mint",o(c,{
 contains:[m()]})),d("mintinline",o(c,{contains:[p(),m()]})),d("url",{
 contains:[p("link"),p("link")]}),d("hyperref",{contains:[p("link")]
@@ -2090,7 +2118,7 @@
 begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"string",begin:'"',
 end:'"'},{className:"variable",begin:"[A-Za-z_][A-Za-z_0-9]*"}]}]}]})})());
 hljs.registerLanguage("less",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],n=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse(),r=i.concat(o)
+;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],n=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse(),r=i.concat(o)
 ;return a=>{const s=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
 HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
 ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
@@ -2808,40 +2836,45 @@
 begin:"\\.\\w*"},e.UNDERSCORE_TITLE_MODE]},{className:"string",begin:'(~)?"',
 end:'"',illegal:"\\n"},{className:"symbol",begin:"#[a-zA-Z_]\\w*\\$?"}]})})());
 hljs.registerLanguage("python",(()=>{"use strict";return e=>{const n={
-keyword:["and","as","assert","async","await","break","class","continue","def","del","elif","else","except","finally","for","","from","global","if","import","in","is","lambda","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],
+$pattern:/[A-Za-z]\w+|__\w+__/,
+keyword:["and","as","assert","async","await","break","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],
 built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"],
-literal:["__debug__","Ellipsis","False","None","NotImplemented","True"]},a={
-className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/,
-end:/\}/,keywords:n,illegal:/#/},i={begin:/\{\{/,relevance:0},r={
+literal:["__debug__","Ellipsis","False","None","NotImplemented","True"],
+type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"]
+},a={className:"meta",begin:/^(>>>|\.\.\.) /},i={className:"subst",begin:/\{/,
+end:/\}/,keywords:n,illegal:/#/},s={begin:/\{\{/,relevance:0},t={
 className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{
 begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/,
 contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{
 begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/,
 contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{
 begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/,
-contains:[e.BACKSLASH_ESCAPE,a,i,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/,
-end:/"""/,contains:[e.BACKSLASH_ESCAPE,a,i,s]},{begin:/([uU]|[rR])'/,end:/'/,
+contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/,
+end:/"""/,contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([uU]|[rR])'/,end:/'/,
 relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{
 begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/,
 end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/,
-contains:[e.BACKSLASH_ESCAPE,i,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/,
-contains:[e.BACKSLASH_ESCAPE,i,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},t="[0-9](_?[0-9])*",l=`(\\b(${t}))?\\.(${t})|\\b(${t})\\.`,b={
+contains:[e.BACKSLASH_ESCAPE,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/,
+contains:[e.BACKSLASH_ESCAPE,s,i]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
+},r="[0-9](_?[0-9])*",l=`(\\b(${r}))?\\.(${r})|\\b(${r})\\.`,b={
 className:"number",relevance:0,variants:[{
-begin:`(\\b(${t})|(${l}))[eE][+-]?(${t})[jJ]?\\b`},{begin:`(${l})[jJ]?`},{
+begin:`(\\b(${r})|(${l}))[eE][+-]?(${r})[jJ]?\\b`},{begin:`(${l})[jJ]?`},{
 begin:"\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?\\b"},{
 begin:"\\b0[bB](_?[01])+[lL]?\\b"},{begin:"\\b0[oO](_?[0-7])+[lL]?\\b"},{
-begin:"\\b0[xX](_?[0-9a-fA-F])+[lL]?\\b"},{begin:`\\b(${t})[jJ]\\b`}]},o={
-className:"params",variants:[{begin:/\(\s*\)/,skip:!0,className:null},{
-begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,
-contains:["self",a,b,r,e.HASH_COMMENT_MODE]}]};return s.contains=[r,b,a],{
-name:"Python",aliases:["py","gyp","ipython"],keywords:n,
-illegal:/(<\/|->|\?)|=>/,contains:[a,b,{begin:/\bself\b/},{beginKeywords:"if",
-relevance:0},r,e.HASH_COMMENT_MODE,{variants:[{className:"function",
-beginKeywords:"def"},{className:"class",beginKeywords:"class"}],end:/:/,
-illegal:/[${=;\n,]/,contains:[e.UNDERSCORE_TITLE_MODE,o,{begin:/->/,
-endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,
-end:/(?=#)|$/,contains:[b,o,r]},{begin:/\b(print|exec)\(/}]}}})());
+begin:"\\b0[xX](_?[0-9a-fA-F])+[lL]?\\b"},{begin:`\\b(${r})[jJ]\\b`}]},o={
+className:"comment",
+begin:(d=/# type:/,((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",d,")")),
+end:/$/,keywords:n,contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,
+endsWithParent:!0}]},c={className:"params",variants:[{className:"",
+begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,
+keywords:n,contains:["self",a,b,t,e.HASH_COMMENT_MODE]}]};var d
+;return i.contains=[t,b,a],{name:"Python",aliases:["py","gyp","ipython"],
+keywords:n,illegal:/(<\/|->|\?)|=>/,contains:[a,b,{begin:/\bself\b/},{
+beginKeywords:"if",relevance:0},t,o,e.HASH_COMMENT_MODE,{variants:[{
+className:"function",beginKeywords:"def"},{className:"class",
+beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,
+contains:[e.UNDERSCORE_TITLE_MODE,c,{begin:/->/,endsWithParent:!0,keywords:n}]
+},{className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[b,c,t]}]}}})());
 hljs.registerLanguage("python-repl",(()=>{"use strict";return s=>({
 aliases:["pycon"],contains:[{className:"meta",starts:{end:/ |$/,starts:{end:"$",
 subLanguage:"python"}},variants:[{begin:/^>>>(?=[ ]|$)/},{
@@ -3029,7 +3062,7 @@
 contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"[\\w\\d]"
 },{begin:e.IDENT_RE+"::",keywords:{built_in:t}},{begin:"->"}]}}})());
 hljs.registerLanguage("sas",(()=>{"use strict";return e=>({name:"SAS",
-aliases:["SAS"],case_insensitive:!0,keywords:{
+case_insensitive:!0,keywords:{
 literal:"null missing _all_ _automatic_ _character_ _infile_ _n_ _name_ _null_ _numeric_ _user_ _webout_",
 meta:"do if then else end until while abort array attrib by call cards cards4 catname continue datalines datalines4 delete delim delimiter display dm drop endsas error file filename footnote format goto in infile informat input keep label leave length libname link list lostcard merge missing modify options output out page put redirect remove rename replace retain return select set skip startsas stop title update waitsas where window x systask add and alter as cascade check create delete describe distinct drop foreign from group having index insert into in key like message modify msgtype not null on or order primary references reset restrict select set table unique update validate view where"
 },contains:[{className:"keyword",begin:/^\s*(proc [\w\d_]+|data|run|quit)[\s;]/
@@ -3091,12 +3124,12 @@
 begin:"[a-zA-Z_][a-zA-Z_0-9]*[\\.']+",relevance:0},{begin:"\\[",
 end:"\\][\\.']*",relevance:0,contains:n},e.COMMENT("//","$")].concat(n)}}})());
 hljs.registerLanguage("scss",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],r=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],o=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
+;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
 ;return a=>{const n=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
 HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
 ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
 illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}
-}))(a),l=r,s=i,d="@[a-z-]+",c={className:"variable",
+}))(a),l=o,s=i,d="@[a-z-]+",c={className:"variable",
 begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b"};return{name:"SCSS",case_insensitive:!0,
 illegal:"[=/|']",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{
 className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{
@@ -3105,7 +3138,7 @@
 begin:"\\b("+e.join("|")+")\\b",relevance:0},{className:"selector-pseudo",
 begin:":("+s.join("|")+")"},{className:"selector-pseudo",
 begin:"::("+l.join("|")+")"},c,{begin:/\(/,end:/\)/,contains:[a.CSS_NUMBER_MODE]
-},{className:"attribute",begin:"\\b("+o.join("|")+")\\b"},{
+},{className:"attribute",begin:"\\b("+r.join("|")+")\\b"},{
 begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"
 },{begin:":",end:";",
 contains:[c,n.HEXCOLOR,a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,n.IMPORTANT]
@@ -3174,7 +3207,7 @@
 begin:/\\\n/,relevance:0},e.inherit(t,{className:"meta-string"}),{
 className:"meta-string",begin:/<[^\n>]*>/,end:/$/,illegal:"\\n"
 },e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]};return{name:"SQF",
-aliases:["sqf"],case_insensitive:!0,keywords:{
+case_insensitive:!0,keywords:{
 keyword:"case catch default do else exit exitWith for forEach from if private switch then throw to try waitUntil while with",
 built_in:"abs accTime acos action actionIDs actionKeys actionKeysImages actionKeysNames actionKeysNamesArray actionName actionParams activateAddons activatedAddons activateKey add3DENConnection add3DENEventHandler add3DENLayer addAction addBackpack addBackpackCargo addBackpackCargoGlobal addBackpackGlobal addCamShake addCuratorAddons addCuratorCameraArea addCuratorEditableObjects addCuratorEditingArea addCuratorPoints addEditorObject addEventHandler addForce addGoggles addGroupIcon addHandgunItem addHeadgear addItem addItemCargo addItemCargoGlobal addItemPool addItemToBackpack addItemToUniform addItemToVest addLiveStats addMagazine addMagazineAmmoCargo addMagazineCargo addMagazineCargoGlobal addMagazineGlobal addMagazinePool addMagazines addMagazineTurret addMenu addMenuItem addMissionEventHandler addMPEventHandler addMusicEventHandler addOwnedMine addPlayerScores addPrimaryWeaponItem addPublicVariableEventHandler addRating addResources addScore addScoreSide addSecondaryWeaponItem addSwitchableUnit addTeamMember addToRemainsCollector addTorque addUniform addVehicle addVest addWaypoint addWeapon addWeaponCargo addWeaponCargoGlobal addWeaponGlobal addWeaponItem addWeaponPool addWeaponTurret admin agent agents AGLToASL aimedAtTarget aimPos airDensityRTD airplaneThrottle airportSide AISFinishHeal alive all3DENEntities allAirports allControls allCurators allCutLayers allDead allDeadMen allDisplays allGroups allMapMarkers allMines allMissionObjects allow3DMode allowCrewInImmobile allowCuratorLogicIgnoreAreas allowDamage allowDammage allowFileOperations allowFleeing allowGetIn allowSprint allPlayers allSimpleObjects allSites allTurrets allUnits allUnitsUAV allVariables ammo ammoOnPylon and animate animateBay animateDoor animatePylon animateSource animationNames animationPhase animationSourcePhase animationState append apply armoryPoints arrayIntersect asin ASLToAGL ASLToATL assert assignAsCargo assignAsCargoIndex assignAsCommander assignAsDriver assignAsGunner assignAsTurret assignCurator assignedCargo assignedCommander assignedDriver assignedGunner assignedItems assignedTarget assignedTeam assignedVehicle assignedVehicleRole assignItem assignTeam assignToAirport atan atan2 atg ATLToASL attachedObject attachedObjects attachedTo attachObject attachTo attackEnabled backpack backpackCargo backpackContainer backpackItems backpackMagazines backpackSpaceFor behaviour benchmark binocular boundingBox boundingBoxReal boundingCenter breakOut breakTo briefingName buildingExit buildingPos buttonAction buttonSetAction cadetMode call callExtension camCommand camCommit camCommitPrepared camCommitted camConstuctionSetParams camCreate camDestroy cameraEffect cameraEffectEnableHUD cameraInterest cameraOn cameraView campaignConfigFile camPreload camPreloaded camPrepareBank camPrepareDir camPrepareDive camPrepareFocus camPrepareFov camPrepareFovRange camPreparePos camPrepareRelPos camPrepareTarget camSetBank camSetDir camSetDive camSetFocus camSetFov camSetFovRange camSetPos camSetRelPos camSetTarget camTarget camUseNVG canAdd canAddItemToBackpack canAddItemToUniform canAddItemToVest cancelSimpleTaskDestination canFire canMove canSlingLoad canStand canSuspend canTriggerDynamicSimulation canUnloadInCombat canVehicleCargo captive captiveNum cbChecked cbSetChecked ceil channelEnabled cheatsEnabled checkAIFeature checkVisibility className clearAllItemsFromBackpack clearBackpackCargo clearBackpackCargoGlobal clearGroupIcons clearItemCargo clearItemCargoGlobal clearItemPool clearMagazineCargo clearMagazineCargoGlobal clearMagazinePool clearOverlay clearRadio clearWeaponCargo clearWeaponCargoGlobal clearWeaponPool clientOwner closeDialog closeDisplay closeOverlay collapseObjectTree collect3DENHistory collectiveRTD combatMode commandArtilleryFire commandChat commander commandFire commandFollow commandFSM commandGetOut commandingMenu commandMove commandRadio commandStop commandSuppressiveFire commandTarget commandWatch comment commitOverlay compile compileFinal completedFSM composeText configClasses configFile configHierarchy configName configProperties configSourceAddonList configSourceMod configSourceModList confirmSensorTarget connectTerminalToUAV controlsGroupCtrl copyFromClipboard copyToClipboard copyWaypoints cos count countEnemy countFriendly countSide countType countUnknown create3DENComposition create3DENEntity createAgent createCenter createDialog createDiaryLink createDiaryRecord createDiarySubject createDisplay createGearDialog createGroup createGuardedPoint createLocation createMarker createMarkerLocal createMenu createMine createMissionDisplay createMPCampaignDisplay createSimpleObject createSimpleTask createSite createSoundSource createTask createTeam createTrigger createUnit createVehicle createVehicleCrew createVehicleLocal crew ctAddHeader ctAddRow ctClear ctCurSel ctData ctFindHeaderRows ctFindRowHeader ctHeaderControls ctHeaderCount ctRemoveHeaders ctRemoveRows ctrlActivate ctrlAddEventHandler ctrlAngle ctrlAutoScrollDelay ctrlAutoScrollRewind ctrlAutoScrollSpeed ctrlChecked ctrlClassName ctrlCommit ctrlCommitted ctrlCreate ctrlDelete ctrlEnable ctrlEnabled ctrlFade ctrlHTMLLoaded ctrlIDC ctrlIDD ctrlMapAnimAdd ctrlMapAnimClear ctrlMapAnimCommit ctrlMapAnimDone ctrlMapCursor ctrlMapMouseOver ctrlMapScale ctrlMapScreenToWorld ctrlMapWorldToScreen ctrlModel ctrlModelDirAndUp ctrlModelScale ctrlParent ctrlParentControlsGroup ctrlPosition ctrlRemoveAllEventHandlers ctrlRemoveEventHandler ctrlScale ctrlSetActiveColor ctrlSetAngle ctrlSetAutoScrollDelay ctrlSetAutoScrollRewind ctrlSetAutoScrollSpeed ctrlSetBackgroundColor ctrlSetChecked ctrlSetEventHandler ctrlSetFade ctrlSetFocus ctrlSetFont ctrlSetFontH1 ctrlSetFontH1B ctrlSetFontH2 ctrlSetFontH2B ctrlSetFontH3 ctrlSetFontH3B ctrlSetFontH4 ctrlSetFontH4B ctrlSetFontH5 ctrlSetFontH5B ctrlSetFontH6 ctrlSetFontH6B ctrlSetFontHeight ctrlSetFontHeightH1 ctrlSetFontHeightH2 ctrlSetFontHeightH3 ctrlSetFontHeightH4 ctrlSetFontHeightH5 ctrlSetFontHeightH6 ctrlSetFontHeightSecondary ctrlSetFontP ctrlSetFontPB ctrlSetFontSecondary ctrlSetForegroundColor ctrlSetModel ctrlSetModelDirAndUp ctrlSetModelScale ctrlSetPixelPrecision ctrlSetPosition ctrlSetScale ctrlSetStructuredText ctrlSetText ctrlSetTextColor ctrlSetTooltip ctrlSetTooltipColorBox ctrlSetTooltipColorShade ctrlSetTooltipColorText ctrlShow ctrlShown ctrlText ctrlTextHeight ctrlTextWidth ctrlType ctrlVisible ctRowControls ctRowCount ctSetCurSel ctSetData ctSetHeaderTemplate ctSetRowTemplate ctSetValue ctValue curatorAddons curatorCamera curatorCameraArea curatorCameraAreaCeiling curatorCoef curatorEditableObjects curatorEditingArea curatorEditingAreaType curatorMouseOver curatorPoints curatorRegisteredObjects curatorSelected curatorWaypointCost current3DENOperation currentChannel currentCommand currentMagazine currentMagazineDetail currentMagazineDetailTurret currentMagazineTurret currentMuzzle currentNamespace currentTask currentTasks currentThrowable currentVisionMode currentWaypoint currentWeapon currentWeaponMode currentWeaponTurret currentZeroing cursorObject cursorTarget customChat customRadio cutFadeOut cutObj cutRsc cutText damage date dateToNumber daytime deActivateKey debriefingText debugFSM debugLog deg delete3DENEntities deleteAt deleteCenter deleteCollection deleteEditorObject deleteGroup deleteGroupWhenEmpty deleteIdentity deleteLocation deleteMarker deleteMarkerLocal deleteRange deleteResources deleteSite deleteStatus deleteTeam deleteVehicle deleteVehicleCrew deleteWaypoint detach detectedMines diag_activeMissionFSMs diag_activeScripts diag_activeSQFScripts diag_activeSQSScripts diag_captureFrame diag_captureFrameToFile diag_captureSlowFrame diag_codePerformance diag_drawMode diag_enable diag_enabled diag_fps diag_fpsMin diag_frameNo diag_lightNewLoad diag_list diag_log diag_logSlowFrame diag_mergeConfigFile diag_recordTurretLimits diag_setLightNew diag_tickTime diag_toggle dialog diarySubjectExists didJIP didJIPOwner difficulty difficultyEnabled difficultyEnabledRTD difficultyOption direction directSay disableAI disableCollisionWith disableConversation disableDebriefingStats disableMapIndicators disableNVGEquipment disableRemoteSensors disableSerialization disableTIEquipment disableUAVConnectability disableUserInput displayAddEventHandler displayCtrl displayParent displayRemoveAllEventHandlers displayRemoveEventHandler displaySetEventHandler dissolveTeam distance distance2D distanceSqr distributionRegion do3DENAction doArtilleryFire doFire doFollow doFSM doGetOut doMove doorPhase doStop doSuppressiveFire doTarget doWatch drawArrow drawEllipse drawIcon drawIcon3D drawLine drawLine3D drawLink drawLocation drawPolygon drawRectangle drawTriangle driver drop dynamicSimulationDistance dynamicSimulationDistanceCoef dynamicSimulationEnabled dynamicSimulationSystemEnabled echo edit3DENMissionAttributes editObject editorSetEventHandler effectiveCommander emptyPositions enableAI enableAIFeature enableAimPrecision enableAttack enableAudioFeature enableAutoStartUpRTD enableAutoTrimRTD enableCamShake enableCaustics enableChannel enableCollisionWith enableCopilot enableDebriefingStats enableDiagLegend enableDynamicSimulation enableDynamicSimulationSystem enableEndDialog enableEngineArtillery enableEnvironment enableFatigue enableGunLights enableInfoPanelComponent enableIRLasers enableMimics enablePersonTurret enableRadio enableReload enableRopeAttach enableSatNormalOnDetail enableSaving enableSentences enableSimulation enableSimulationGlobal enableStamina enableTeamSwitch enableTraffic enableUAVConnectability enableUAVWaypoints enableVehicleCargo enableVehicleSensor enableWeaponDisassembly endLoadingScreen endMission engineOn enginesIsOnRTD enginesRpmRTD enginesTorqueRTD entities environmentEnabled estimatedEndServerTime estimatedTimeLeft evalObjectArgument everyBackpack everyContainer exec execEditorScript execFSM execVM exp expectedDestination exportJIPMessages eyeDirection eyePos face faction fadeMusic fadeRadio fadeSound fadeSpeech failMission fillWeaponsFromPool find findCover findDisplay findEditorObject findEmptyPosition findEmptyPositionReady findIf findNearestEnemy finishMissionInit finite fire fireAtTarget firstBackpack flag flagAnimationPhase flagOwner flagSide flagTexture fleeing floor flyInHeight flyInHeightASL fog fogForecast fogParams forceAddUniform forcedMap forceEnd forceFlagTexture forceFollowRoad forceMap forceRespawn forceSpeed forceWalk forceWeaponFire forceWeatherChange forEachMember forEachMemberAgent forEachMemberTeam forgetTarget format formation formationDirection formationLeader formationMembers formationPosition formationTask formatText formLeader freeLook fromEditor fuel fullCrew gearIDCAmmoCount gearSlotAmmoCount gearSlotData get3DENActionState get3DENAttribute get3DENCamera get3DENConnections get3DENEntity get3DENEntityID get3DENGrid get3DENIconsVisible get3DENLayerEntities get3DENLinesVisible get3DENMissionAttribute get3DENMouseOver get3DENSelected getAimingCoef getAllEnvSoundControllers getAllHitPointsDamage getAllOwnedMines getAllSoundControllers getAmmoCargo getAnimAimPrecision getAnimSpeedCoef getArray getArtilleryAmmo getArtilleryComputerSettings getArtilleryETA getAssignedCuratorLogic getAssignedCuratorUnit getBackpackCargo getBleedingRemaining getBurningValue getCameraViewDirection getCargoIndex getCenterOfMass getClientState getClientStateNumber getCompatiblePylonMagazines getConnectedUAV getContainerMaxLoad getCursorObjectParams getCustomAimCoef getDammage getDescription getDir getDirVisual getDLCAssetsUsage getDLCAssetsUsageByName getDLCs getEditorCamera getEditorMode getEditorObjectScope getElevationOffset getEnvSoundController getFatigue getForcedFlagTexture getFriend getFSMVariable getFuelCargo getGroupIcon getGroupIconParams getGroupIcons getHideFrom getHit getHitIndex getHitPointDamage getItemCargo getMagazineCargo getMarkerColor getMarkerPos getMarkerSize getMarkerType getMass getMissionConfig getMissionConfigValue getMissionDLCs getMissionLayerEntities getModelInfo getMousePosition getMusicPlayedTime getNumber getObjectArgument getObjectChildren getObjectDLC getObjectMaterials getObjectProxy getObjectTextures getObjectType getObjectViewDistance getOxygenRemaining getPersonUsedDLCs getPilotCameraDirection getPilotCameraPosition getPilotCameraRotation getPilotCameraTarget getPlateNumber getPlayerChannel getPlayerScores getPlayerUID getPos getPosASL getPosASLVisual getPosASLW getPosATL getPosATLVisual getPosVisual getPosWorld getPylonMagazines getRelDir getRelPos getRemoteSensorsDisabled getRepairCargo getResolution getShadowDistance getShotParents getSlingLoad getSoundController getSoundControllerResult getSpeed getStamina getStatValue getSuppression getTerrainGrid getTerrainHeightASL getText getTotalDLCUsageTime getUnitLoadout getUnitTrait getUserMFDText getUserMFDvalue getVariable getVehicleCargo getWeaponCargo getWeaponSway getWingsOrientationRTD getWingsPositionRTD getWPPos glanceAt globalChat globalRadio goggles goto group groupChat groupFromNetId groupIconSelectable groupIconsVisible groupId groupOwner groupRadio groupSelectedUnits groupSelectUnit gunner gusts halt handgunItems handgunMagazine handgunWeapon handsHit hasInterface hasPilotCamera hasWeapon hcAllGroups hcGroupParams hcLeader hcRemoveAllGroups hcRemoveGroup hcSelected hcSelectGroup hcSetGroup hcShowBar hcShownBar headgear hideBody hideObject hideObjectGlobal hideSelection hint hintC hintCadet hintSilent hmd hostMission htmlLoad HUDMovementLevels humidity image importAllGroups importance in inArea inAreaArray incapacitatedState inflame inflamed infoPanel infoPanelComponentEnabled infoPanelComponents infoPanels inGameUISetEventHandler inheritsFrom initAmbientLife inPolygon inputAction inRangeOfArtillery insertEditorObject intersect is3DEN is3DENMultiplayer isAbleToBreathe isAgent isArray isAutoHoverOn isAutonomous isAutotest isBleeding isBurning isClass isCollisionLightOn isCopilotEnabled isDamageAllowed isDedicated isDLCAvailable isEngineOn isEqualTo isEqualType isEqualTypeAll isEqualTypeAny isEqualTypeArray isEqualTypeParams isFilePatchingEnabled isFlashlightOn isFlatEmpty isForcedWalk isFormationLeader isGroupDeletedWhenEmpty isHidden isInRemainsCollector isInstructorFigureEnabled isIRLaserOn isKeyActive isKindOf isLaserOn isLightOn isLocalized isManualFire isMarkedForCollection isMultiplayer isMultiplayerSolo isNil isNull isNumber isObjectHidden isObjectRTD isOnRoad isPipEnabled isPlayer isRealTime isRemoteExecuted isRemoteExecutedJIP isServer isShowing3DIcons isSimpleObject isSprintAllowed isStaminaEnabled isSteamMission isStreamFriendlyUIEnabled isText isTouchingGround isTurnedOut isTutHintsEnabled isUAVConnectable isUAVConnected isUIContext isUniformAllowed isVehicleCargo isVehicleRadarOn isVehicleSensorEnabled isWalking isWeaponDeployed isWeaponRested itemCargo items itemsWithMagazines join joinAs joinAsSilent joinSilent joinString kbAddDatabase kbAddDatabaseTargets kbAddTopic kbHasTopic kbReact kbRemoveTopic kbTell kbWasSaid keyImage keyName knowsAbout land landAt landResult language laserTarget lbAdd lbClear lbColor lbColorRight lbCurSel lbData lbDelete lbIsSelected lbPicture lbPictureRight lbSelection lbSetColor lbSetColorRight lbSetCurSel lbSetData lbSetPicture lbSetPictureColor lbSetPictureColorDisabled lbSetPictureColorSelected lbSetPictureRight lbSetPictureRightColor lbSetPictureRightColorDisabled lbSetPictureRightColorSelected lbSetSelectColor lbSetSelectColorRight lbSetSelected lbSetText lbSetTextRight lbSetTooltip lbSetValue lbSize lbSort lbSortByValue lbText lbTextRight lbValue leader leaderboardDeInit leaderboardGetRows leaderboardInit leaderboardRequestRowsFriends leaderboardsRequestUploadScore leaderboardsRequestUploadScoreKeepBest leaderboardState leaveVehicle libraryCredits libraryDisclaimers lifeState lightAttachObject lightDetachObject lightIsOn lightnings limitSpeed linearConversion lineIntersects lineIntersectsObjs lineIntersectsSurfaces lineIntersectsWith linkItem list listObjects listRemoteTargets listVehicleSensors ln lnbAddArray lnbAddColumn lnbAddRow lnbClear lnbColor lnbCurSelRow lnbData lnbDeleteColumn lnbDeleteRow lnbGetColumnsPosition lnbPicture lnbSetColor lnbSetColumnsPos lnbSetCurSelRow lnbSetData lnbSetPicture lnbSetText lnbSetValue lnbSize lnbSort lnbSortByValue lnbText lnbValue load loadAbs loadBackpack loadFile loadGame loadIdentity loadMagazine loadOverlay loadStatus loadUniform loadVest local localize locationPosition lock lockCameraTo lockCargo lockDriver locked lockedCargo lockedDriver lockedTurret lockIdentity lockTurret lockWP log logEntities logNetwork logNetworkTerminate lookAt lookAtPos magazineCargo magazines magazinesAllTurrets magazinesAmmo magazinesAmmoCargo magazinesAmmoFull magazinesDetail magazinesDetailBackpack magazinesDetailUniform magazinesDetailVest magazinesTurret magazineTurretAmmo mapAnimAdd mapAnimClear mapAnimCommit mapAnimDone mapCenterOnCamera mapGridPosition markAsFinishedOnSteam markerAlpha markerBrush markerColor markerDir markerPos markerShape markerSize markerText markerType max members menuAction menuAdd menuChecked menuClear menuCollapse menuData menuDelete menuEnable menuEnabled menuExpand menuHover menuPicture menuSetAction menuSetCheck menuSetData menuSetPicture menuSetValue menuShortcut menuShortcutText menuSize menuSort menuText menuURL menuValue min mineActive mineDetectedBy missionConfigFile missionDifficulty missionName missionNamespace missionStart missionVersion mod modelToWorld modelToWorldVisual modelToWorldVisualWorld modelToWorldWorld modParams moonIntensity moonPhase morale move move3DENCamera moveInAny moveInCargo moveInCommander moveInDriver moveInGunner moveInTurret moveObjectToEnd moveOut moveTime moveTo moveToCompleted moveToFailed musicVolume name nameSound nearEntities nearestBuilding nearestLocation nearestLocations nearestLocationWithDubbing nearestObject nearestObjects nearestTerrainObjects nearObjects nearObjectsReady nearRoads nearSupplies nearTargets needReload netId netObjNull newOverlay nextMenuItemIndex nextWeatherChange nMenuItems not numberOfEnginesRTD numberToDate objectCurators objectFromNetId objectParent objStatus onBriefingGroup onBriefingNotes onBriefingPlan onBriefingTeamSwitch onCommandModeChanged onDoubleClick onEachFrame onGroupIconClick onGroupIconOverEnter onGroupIconOverLeave onHCGroupSelectionChanged onMapSingleClick onPlayerConnected onPlayerDisconnected onPreloadFinished onPreloadStarted onShowNewObject onTeamSwitch openCuratorInterface openDLCPage openMap openSteamApp openYoutubeVideo or orderGetIn overcast overcastForecast owner param params parseNumber parseSimpleArray parseText parsingNamespace particlesQuality pickWeaponPool pitch pixelGrid pixelGridBase pixelGridNoUIScale pixelH pixelW playableSlotsNumber playableUnits playAction playActionNow player playerRespawnTime playerSide playersNumber playGesture playMission playMove playMoveNow playMusic playScriptedMission playSound playSound3D position positionCameraToWorld posScreenToWorld posWorldToScreen ppEffectAdjust ppEffectCommit ppEffectCommitted ppEffectCreate ppEffectDestroy ppEffectEnable ppEffectEnabled ppEffectForceInNVG precision preloadCamera preloadObject preloadSound preloadTitleObj preloadTitleRsc preprocessFile preprocessFileLineNumbers primaryWeapon primaryWeaponItems primaryWeaponMagazine priority processDiaryLink productVersion profileName profileNamespace profileNameSteam progressLoadingScreen progressPosition progressSetPosition publicVariable publicVariableClient publicVariableServer pushBack pushBackUnique putWeaponPool queryItemsPool queryMagazinePool queryWeaponPool rad radioChannelAdd radioChannelCreate radioChannelRemove radioChannelSetCallSign radioChannelSetLabel radioVolume rain rainbow random rank rankId rating rectangular registeredTasks registerTask reload reloadEnabled remoteControl remoteExec remoteExecCall remoteExecutedOwner remove3DENConnection remove3DENEventHandler remove3DENLayer removeAction removeAll3DENEventHandlers removeAllActions removeAllAssignedItems removeAllContainers removeAllCuratorAddons removeAllCuratorCameraAreas removeAllCuratorEditingAreas removeAllEventHandlers removeAllHandgunItems removeAllItems removeAllItemsWithMagazines removeAllMissionEventHandlers removeAllMPEventHandlers removeAllMusicEventHandlers removeAllOwnedMines removeAllPrimaryWeaponItems removeAllWeapons removeBackpack removeBackpackGlobal removeCuratorAddons removeCuratorCameraArea removeCuratorEditableObjects removeCuratorEditingArea removeDrawIcon removeDrawLinks removeEventHandler removeFromRemainsCollector removeGoggles removeGroupIcon removeHandgunItem removeHeadgear removeItem removeItemFromBackpack removeItemFromUniform removeItemFromVest removeItems removeMagazine removeMagazineGlobal removeMagazines removeMagazinesTurret removeMagazineTurret removeMenuItem removeMissionEventHandler removeMPEventHandler removeMusicEventHandler removeOwnedMine removePrimaryWeaponItem removeSecondaryWeaponItem removeSimpleTask removeSwitchableUnit removeTeamMember removeUniform removeVest removeWeapon removeWeaponAttachmentCargo removeWeaponCargo removeWeaponGlobal removeWeaponTurret reportRemoteTarget requiredVersion resetCamShake resetSubgroupDirection resize resources respawnVehicle restartEditorCamera reveal revealMine reverse reversedMouseY roadAt roadsConnectedTo roleDescription ropeAttachedObjects ropeAttachedTo ropeAttachEnabled ropeAttachTo ropeCreate ropeCut ropeDestroy ropeDetach ropeEndPosition ropeLength ropes ropeUnwind ropeUnwound rotorsForcesRTD rotorsRpmRTD round runInitScript safeZoneH safeZoneW safeZoneWAbs safeZoneX safeZoneXAbs safeZoneY save3DENInventory saveGame saveIdentity saveJoysticks saveOverlay saveProfileNamespace saveStatus saveVar savingEnabled say say2D say3D scopeName score scoreSide screenshot screenToWorld scriptDone scriptName scudState secondaryWeapon secondaryWeaponItems secondaryWeaponMagazine select selectBestPlaces selectDiarySubject selectedEditorObjects selectEditorObject selectionNames selectionPosition selectLeader selectMax selectMin selectNoPlayer selectPlayer selectRandom selectRandomWeighted selectWeapon selectWeaponTurret sendAUMessage sendSimpleCommand sendTask sendTaskResult sendUDPMessage serverCommand serverCommandAvailable serverCommandExecutable serverName serverTime set set3DENAttribute set3DENAttributes set3DENGrid set3DENIconsVisible set3DENLayer set3DENLinesVisible set3DENLogicType set3DENMissionAttribute set3DENMissionAttributes set3DENModelsVisible set3DENObjectType set3DENSelected setAccTime setActualCollectiveRTD setAirplaneThrottle setAirportSide setAmmo setAmmoCargo setAmmoOnPylon setAnimSpeedCoef setAperture setApertureNew setArmoryPoints setAttributes setAutonomous setBehaviour setBleedingRemaining setBrakesRTD setCameraInterest setCamShakeDefParams setCamShakeParams setCamUseTI setCaptive setCenterOfMass setCollisionLight setCombatMode setCompassOscillation setConvoySeparation setCuratorCameraAreaCeiling setCuratorCoef setCuratorEditingAreaType setCuratorWaypointCost setCurrentChannel setCurrentTask setCurrentWaypoint setCustomAimCoef setCustomWeightRTD setDamage setDammage setDate setDebriefingText setDefaultCamera setDestination setDetailMapBlendPars setDir setDirection setDrawIcon setDriveOnPath setDropInterval setDynamicSimulationDistance setDynamicSimulationDistanceCoef setEditorMode setEditorObjectScope setEffectCondition setEngineRPMRTD setFace setFaceAnimation setFatigue setFeatureType setFlagAnimationPhase setFlagOwner setFlagSide setFlagTexture setFog setFormation setFormationTask setFormDir setFriend setFromEditor setFSMVariable setFuel setFuelCargo setGroupIcon setGroupIconParams setGroupIconsSelectable setGroupIconsVisible setGroupId setGroupIdGlobal setGroupOwner setGusts setHideBehind setHit setHitIndex setHitPointDamage setHorizonParallaxCoef setHUDMovementLevels setIdentity setImportance setInfoPanel setLeader setLightAmbient setLightAttenuation setLightBrightness setLightColor setLightDayLight setLightFlareMaxDistance setLightFlareSize setLightIntensity setLightnings setLightUseFlare setLocalWindParams setMagazineTurretAmmo setMarkerAlpha setMarkerAlphaLocal setMarkerBrush setMarkerBrushLocal setMarkerColor setMarkerColorLocal setMarkerDir setMarkerDirLocal setMarkerPos setMarkerPosLocal setMarkerShape setMarkerShapeLocal setMarkerSize setMarkerSizeLocal setMarkerText setMarkerTextLocal setMarkerType setMarkerTypeLocal setMass setMimic setMousePosition setMusicEffect setMusicEventHandler setName setNameSound setObjectArguments setObjectMaterial setObjectMaterialGlobal setObjectProxy setObjectTexture setObjectTextureGlobal setObjectViewDistance setOvercast setOwner setOxygenRemaining setParticleCircle setParticleClass setParticleFire setParticleParams setParticleRandom setPilotCameraDirection setPilotCameraRotation setPilotCameraTarget setPilotLight setPiPEffect setPitch setPlateNumber setPlayable setPlayerRespawnTime setPos setPosASL setPosASL2 setPosASLW setPosATL setPosition setPosWorld setPylonLoadOut setPylonsPriority setRadioMsg setRain setRainbow setRandomLip setRank setRectangular setRepairCargo setRotorBrakeRTD setShadowDistance setShotParents setSide setSimpleTaskAlwaysVisible setSimpleTaskCustomData setSimpleTaskDescription setSimpleTaskDestination setSimpleTaskTarget setSimpleTaskType setSimulWeatherLayers setSize setSkill setSlingLoad setSoundEffect setSpeaker setSpeech setSpeedMode setStamina setStaminaScheme setStatValue setSuppression setSystemOfUnits setTargetAge setTaskMarkerOffset setTaskResult setTaskState setTerrainGrid setText setTimeMultiplier setTitleEffect setTrafficDensity setTrafficDistance setTrafficGap setTrafficSpeed setTriggerActivation setTriggerArea setTriggerStatements setTriggerText setTriggerTimeout setTriggerType setType setUnconscious setUnitAbility setUnitLoadout setUnitPos setUnitPosWeak setUnitRank setUnitRecoilCoefficient setUnitTrait setUnloadInCombat setUserActionText setUserMFDText setUserMFDvalue setVariable setVectorDir setVectorDirAndUp setVectorUp setVehicleAmmo setVehicleAmmoDef setVehicleArmor setVehicleCargo setVehicleId setVehicleLock setVehiclePosition setVehicleRadar setVehicleReceiveRemoteTargets setVehicleReportOwnPosition setVehicleReportRemoteTargets setVehicleTIPars setVehicleVarName setVelocity setVelocityModelSpace setVelocityTransformation setViewDistance setVisibleIfTreeCollapsed setWantedRPMRTD setWaves setWaypointBehaviour setWaypointCombatMode setWaypointCompletionRadius setWaypointDescription setWaypointForceBehaviour setWaypointFormation setWaypointHousePosition setWaypointLoiterRadius setWaypointLoiterType setWaypointName setWaypointPosition setWaypointScript setWaypointSpeed setWaypointStatements setWaypointTimeout setWaypointType setWaypointVisible setWeaponReloadingTime setWind setWindDir setWindForce setWindStr setWingForceScaleRTD setWPPos show3DIcons showChat showCinemaBorder showCommandingMenu showCompass showCuratorCompass showGPS showHUD showLegend showMap shownArtilleryComputer shownChat shownCompass shownCuratorCompass showNewEditorObject shownGPS shownHUD shownMap shownPad shownRadio shownScoretable shownUAVFeed shownWarrant shownWatch showPad showRadio showScoretable showSubtitles showUAVFeed showWarrant showWatch showWaypoint showWaypoints side sideChat sideEnemy sideFriendly sideRadio simpleTasks simulationEnabled simulCloudDensity simulCloudOcclusion simulInClouds simulWeatherSync sin size sizeOf skill skillFinal skipTime sleep sliderPosition sliderRange sliderSetPosition sliderSetRange sliderSetSpeed sliderSpeed slingLoadAssistantShown soldierMagazines someAmmo sort soundVolume spawn speaker speed speedMode splitString sqrt squadParams stance startLoadingScreen step stop stopEngineRTD stopped str sunOrMoon supportInfo suppressFor surfaceIsWater surfaceNormal surfaceType swimInDepth switchableUnits switchAction switchCamera switchGesture switchLight switchMove synchronizedObjects synchronizedTriggers synchronizedWaypoints synchronizeObjectsAdd synchronizeObjectsRemove synchronizeTrigger synchronizeWaypoint systemChat systemOfUnits tan targetKnowledge targets targetsAggregate targetsQuery taskAlwaysVisible taskChildren taskCompleted taskCustomData taskDescription taskDestination taskHint taskMarkerOffset taskParent taskResult taskState taskType teamMember teamName teams teamSwitch teamSwitchEnabled teamType terminate terrainIntersect terrainIntersectASL terrainIntersectAtASL text textLog textLogFormat tg time timeMultiplier titleCut titleFadeOut titleObj titleRsc titleText toArray toFixed toLower toString toUpper triggerActivated triggerActivation triggerArea triggerAttachedVehicle triggerAttachObject triggerAttachVehicle triggerDynamicSimulation triggerStatements triggerText triggerTimeout triggerTimeoutCurrent triggerType turretLocal turretOwner turretUnit tvAdd tvClear tvCollapse tvCollapseAll tvCount tvCurSel tvData tvDelete tvExpand tvExpandAll tvPicture tvSetColor tvSetCurSel tvSetData tvSetPicture tvSetPictureColor tvSetPictureColorDisabled tvSetPictureColorSelected tvSetPictureRight tvSetPictureRightColor tvSetPictureRightColorDisabled tvSetPictureRightColorSelected tvSetText tvSetTooltip tvSetValue tvSort tvSortByValue tvText tvTooltip tvValue type typeName typeOf UAVControl uiNamespace uiSleep unassignCurator unassignItem unassignTeam unassignVehicle underwater uniform uniformContainer uniformItems uniformMagazines unitAddons unitAimPosition unitAimPositionVisual unitBackpack unitIsUAV unitPos unitReady unitRecoilCoefficient units unitsBelowHeight unlinkItem unlockAchievement unregisterTask updateDrawIcon updateMenuItem updateObjectTree useAISteeringComponent useAudioTimeForMoves userInputDisabled vectorAdd vectorCos vectorCrossProduct vectorDiff vectorDir vectorDirVisual vectorDistance vectorDistanceSqr vectorDotProduct vectorFromTo vectorMagnitude vectorMagnitudeSqr vectorModelToWorld vectorModelToWorldVisual vectorMultiply vectorNormalized vectorUp vectorUpVisual vectorWorldToModel vectorWorldToModelVisual vehicle vehicleCargoEnabled vehicleChat vehicleRadio vehicleReceiveRemoteTargets vehicleReportOwnPosition vehicleReportRemoteTargets vehicles vehicleVarName velocity velocityModelSpace verifySignature vest vestContainer vestItems vestMagazines viewDistance visibleCompass visibleGPS visibleMap visiblePosition visiblePositionASL visibleScoretable visibleWatch waves waypointAttachedObject waypointAttachedVehicle waypointAttachObject waypointAttachVehicle waypointBehaviour waypointCombatMode waypointCompletionRadius waypointDescription waypointForceBehaviour waypointFormation waypointHousePosition waypointLoiterRadius waypointLoiterType waypointName waypointPosition waypoints waypointScript waypointsEnabledUAV waypointShow waypointSpeed waypointStatements waypointTimeout waypointTimeoutCurrent waypointType waypointVisible weaponAccessories weaponAccessoriesCargo weaponCargo weaponDirection weaponInertia weaponLowered weapons weaponsItems weaponsItemsCargo weaponState weaponsTurret weightRTD WFSideText wind ",
 literal:"blufor civilian configNull controlNull displayNull east endl false grpNull independent lineBreak locationNull nil objNull opfor pi resistance scriptNull sideAmbientLife sideEmpty sideLogic sideUnknown taskNull teamMemberNull true west"
@@ -3247,7 +3280,7 @@
 className:"string",begin:"'",end:"'"},{className:"symbol",variants:[{begin:"#",
 end:"\\d+",illegal:"\\W"}]}]})})());
 hljs.registerLanguage("stylus",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
+;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],o=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],i=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
 ;return n=>{const a=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
 HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
 ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
@@ -3259,8 +3292,8 @@
 begin:"\\.[a-zA-Z][a-zA-Z0-9_-]*(?=[.\\s\\n[:,(])",className:"selector-class"},{
 begin:"#[a-zA-Z][a-zA-Z0-9_-]*(?=[.\\s\\n[:,(])",className:"selector-id"},{
 begin:"\\b("+e.join("|")+")"+l,className:"selector-tag"},{
-className:"selector-pseudo",begin:"&?:("+i.join("|")+")"+l},{
-className:"selector-pseudo",begin:"&?::("+o.join("|")+")"+l
+className:"selector-pseudo",begin:"&?:("+o.join("|")+")"+l},{
+className:"selector-pseudo",begin:"&?::("+i.join("|")+")"+l
 },a.ATTRIBUTE_SELECTOR_MODE,{className:"keyword",begin:/@media/,starts:{
 end:/[{;}]/,keywords:{$pattern:/[a-z-]+/,keyword:"and or not only",
 attribute:t.join(" ")},contains:[n.CSS_NUMBER_MODE]}},{className:"keyword",
@@ -3286,7 +3319,7 @@
 return e?"string"==typeof e?e:e.source:null}function n(e){return a("(?=",e,")")}
 function a(...n){return n.map((n=>e(n))).join("")}function t(...n){
 return"("+n.map((n=>e(n))).join("|")+")"}
-const i=e=>a(/\b/,e,/\w$/.test(e)?/\b/:/\B/),s=["Protocol","Type"].map(i),u=["init","self"].map(i),c=["Any","Self"],r=["associatedtype",/as\?/,/as!/,"as","break","case","catch","class","continue","convenience","default","defer","deinit","didSet","do","dynamic","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","lazy","let","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],o=["false","nil","true"],l=["assignment","associativity","higherThan","left","lowerThan","none","right"],m=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warn_unqualified_access","#warning"],d=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],p=t(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),F=t(p,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),b=a(p,F,"*"),h=t(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),f=t(h,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),w=a(h,f,"*"),y=a(/[A-Z]/,f,"*"),g=["autoclosure",a(/convention\(/,t("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",a(/objc\(/,w,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","testable","UIApplicationMain","unknown","usableFromInline"],E=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"]
+const i=e=>a(/\b/,e,/\w$/.test(e)?/\b/:/\B/),s=["Protocol","Type"].map(i),u=["init","self"].map(i),c=["Any","Self"],r=["associatedtype","async","await",/as\?/,/as!/,"as","break","case","catch","class","continue","convenience","default","defer","deinit","didSet","do","dynamic","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","lazy","let","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],o=["false","nil","true"],l=["assignment","associativity","higherThan","left","lowerThan","none","right"],m=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warn_unqualified_access","#warning"],d=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],p=t(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),F=t(p,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),b=a(p,F,"*"),h=t(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),f=t(h,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),w=a(h,f,"*"),y=a(/[A-Z]/,f,"*"),g=["autoclosure",a(/convention\(/,t("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",a(/objc\(/,w,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","testable","UIApplicationMain","unknown","usableFromInline"],E=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"]
 ;return e=>{const p={match:/\s+/,relevance:0},h=e.COMMENT("/\\*","\\*/",{
 contains:["self"]}),v=[e.C_LINE_COMMENT_MODE,h],N={className:"keyword",
 begin:a(/\./,n(t(...s,...u))),end:t(...s,...u),excludeBegin:!0},A={
@@ -3372,7 +3405,7 @@
 begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"
 },{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},t,g,s],r=[...b]
 ;return r.pop(),r.push(i),l.contains=r,{name:"YAML",case_insensitive:!0,
-aliases:["yml","YAML"],contains:b}}})());
+aliases:["yml"],contains:b}}})());
 hljs.registerLanguage("tap",(()=>{"use strict";return e=>({
 name:"Test Anything Protocol",case_insensitive:!0,
 contains:[e.HASH_COMMENT_MODE,{className:"meta",variants:[{
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 8369024..13e81f7 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -12,6 +12,7 @@
 
 cat << EOF > $TMP/want
 cglib-3_2
+commons-io
 docker-java-api
 docker-java-transport
 dropwizard-core
@@ -21,13 +22,21 @@
 flogger
 flogger-log4j-backend
 flogger-system-backend
+guice-assistedinject
+guice-library-no-aop
+guice-servlet
 httpasyncclient
 httpcore-nio
+impl-log4j
 j2objc
 jackson-annotations
 jackson-core
+jcl-over-slf4j
 jna
 jruby
+log-api
+log-ext
+log4j
 mina-core
 nekohtml
 objenesis
diff --git a/lib/polymer_externs/BUILD b/lib/polymer_externs/BUILD
deleted file mode 100644
index f07aa2f..0000000
--- a/lib/polymer_externs/BUILD
+++ /dev/null
@@ -1,24 +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.
-
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library")
-
-package(default_visibility = ["//visibility:public"])
-
-closure_js_library(
-    name = "polymer_closure",
-    srcs = ["@polymer_closure//file"],
-    data = ["//lib:LICENSE-Apache2.0"],
-    no_closure_library = True,
-)
diff --git a/modules/jgit b/modules/jgit
index 8470771..c6b0ee0 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 84707715108a65a366ef35f2ae04aabecd0b35f6
+Subproject commit c6b0ee04e49c96e0beec4154196c416abcf2bcc9
diff --git a/package.json b/package.json
index 5b9046d..c75795d 100644
--- a/package.json
+++ b/package.json
@@ -4,27 +4,35 @@
   "description": "Gerrit Code Review",
   "dependencies": {},
   "devDependencies": {
-    "@bazel/rollup": "^1.1.0",
-    "@bazel/typescript": "^1.0.1",
+    "@bazel/concatjs": "^5.1.0",
+    "@bazel/rollup": "^5.1.0",
+    "@bazel/terser": "^5.1.0",
+    "@bazel/typescript": "^5.1.0",
     "eslint": "^6.6.0",
     "eslint-config-google": "^0.13.0",
     "eslint-plugin-html": "^6.0.0",
     "eslint-plugin-import": "^2.20.1",
     "eslint-plugin-jsdoc": "^19.2.0",
     "eslint-plugin-prettier": "^3.1.3",
+    "gts": "^2.0.2",
     "polymer-cli": "^1.9.11",
     "prettier": "2.0.5",
-    "typescript": "^3.7.4",
-    "web-component-tester": "^6.5.1"
+    "rollup": "^2.3.4",
+    "terser": "^4.8.0",
+    "typescript": "3.9.5"
   },
   "scripts": {
     "clean": "git clean -fdx && bazel clean --expunge",
+    "compile:local": "tsc --project ./polygerrit-ui/app/tsconfig.json",
+    "compile:watch": "npm run compile:local -- --preserveWatchOutput --watch",
     "start": "polygerrit-ui/run-server.sh",
-    "test": "WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh",
+    "test": "./polygerrit-ui/app/run_test.sh",
     "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"
+    "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test",
+    "test:debug": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
+    "test:single": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles"
   },
   "repository": {
     "type": "git",
diff --git a/plugins/BUILD b/plugins/BUILD
index a071bde..943471a 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -49,6 +49,7 @@
     "//java/com/google/gerrit/server/audit",
     "//java/com/google/gerrit/server/cache/mem",
     "//java/com/google/gerrit/server/cache/serialize",
+    "//java/com/google/gerrit/server/data",
     "//java/com/google/gerrit/server/logging",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/server/util/time",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index e211fb1..c621796 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit e211fb1bd21043e2574c438a687c8f492d538c97
+Subproject commit c6217963e42322accc3f0bacb6540f8791f67ab0
diff --git a/plugins/delete-project b/plugins/delete-project
index 407c15e..7d060da 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 407c15e7949ad5a086ba5c8998be68964ff17de5
+Subproject commit 7d060dab5f311ec498f9a3b0aa58ce797b8c4d28
diff --git a/plugins/download-commands b/plugins/download-commands
index e26ed31..87e3930 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit e26ed31aaf070ff884e96b9a09d39c20437de6cb
+Subproject commit 87e3930cea7c06aea454998abdddf6515a9f103b
diff --git a/plugins/gitiles b/plugins/gitiles
index d5fba00..d83dc8b 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit d5fba00a6d1e233434eaef38927f8a8b58bc830a
+Subproject commit d83dc8b0cd4b56a769b4c6ce909ba9c3a79aa2a1
diff --git a/plugins/hooks b/plugins/hooks
index 7ed555f..ad4f877 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 7ed555fe88f4be028acbfd5c245ac78537ac3666
+Subproject commit ad4f877749928b69ef94b62176c5797f6648887d
diff --git a/plugins/package.json b/plugins/package.json
new file mode 100644
index 0000000..9f5c649
--- /dev/null
+++ b/plugins/package.json
@@ -0,0 +1,8 @@
+{
+    "name": "polygerrit-plugin-dependencies-placeholder",
+    "description": "Gerrit Code Review - Polygerrit plugin dependencies placeholder, expected to be overridden by plugins",
+    "browser": true,
+    "dependencies": {},
+    "license": "Apache-2.0",
+    "private": true
+}
\ No newline at end of file
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index 783f5c6..00e5794 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit 783f5c65c7dca522658efe10d57d1ac9ab5f9007
+Subproject commit 00e57948f4f112c226028bc5c8d8fe60f770038f
diff --git a/plugins/replication b/plugins/replication
index d9f7c93..fb4854b 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit d9f7c9387119e67bef20fe795975763f3bf50be9
+Subproject commit fb4854b57300f1060690b59722eae8c3a18cdab5
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index e952b92..fb0390a 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit e952b920ecbee5225f1098a02d4a39b19aa7e234
+Subproject commit fb0390a8b49f0d601e11f8a1ac0658c429727f21
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index b594c7f..30ca9c1 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit b594c7f7d6ed77317fa7602dc5d6ff354899bb78
+Subproject commit 30ca9c1fa624b7389703dd8f8d35cff778e60d83
diff --git a/plugins/webhooks b/plugins/webhooks
index 76b829e..83dda66 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 76b829e09dcab780462652cb33c882a6d0074dac
+Subproject commit 83dda664bf0ad96249dfd99a03b11d2eaf32703d
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
new file mode 100644
index 0000000..a63f96e
--- /dev/null
+++ b/plugins/yarn.lock
@@ -0,0 +1,3 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+# This is an empty placeholder
\ No newline at end of file
diff --git a/polygerrit-ui/.gitignore b/polygerrit-ui/.gitignore
index 7f06bef..7b33a59 100644
--- a/polygerrit-ui/.gitignore
+++ b/polygerrit-ui/.gitignore
@@ -4,4 +4,4 @@
 fonts
 bower_components
 .tmp
-.vscode
\ No newline at end of file
+.vscode
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index a029df4..7bca96d 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -32,3 +32,43 @@
         "@org_golang_x_tools//godoc/vfs/zipfs:go_default_library",
     ],
 )
+
+# Define a karma+plugins binary to run karma-mocha tests.
+# Can be reused multiple time, if there are multiple karma test rules
+sh_binary(
+    name = "karma_bin",
+    srcs = ["@ui_dev_npm//:node_modules/karma/bin/karma"],
+    data = [
+        "@ui_dev_npm//@open-wc/karma-esm",
+        "@ui_dev_npm//chai",
+        "@ui_dev_npm//karma-chrome-launcher",
+        "@ui_dev_npm//karma-mocha",
+        "@ui_dev_npm//karma-mocha-reporter",
+        "@ui_dev_npm//karma/bin:karma",
+        "@ui_dev_npm//mocha",
+    ],
+)
+
+# Run all tests in one.
+# TODO(dmfilippov): allow parallel tests for karma - either on the bazel level
+# or on the karma level. For now single sh_test is enough.
+sh_test(
+    name = "karma_test",
+    size = "enormous",
+    srcs = ["karma_test.sh"],
+    args = [
+        "$(location :karma_bin)",
+        "$(location karma.conf.js)",
+    ],
+    data = [
+        "karma.conf.js",
+        ":karma_bin",
+        "//polygerrit-ui/app:test-srcs-fg",
+    ],
+    # Should not run sandboxed.
+    tags = [
+        "karma",
+        "local",
+        "manual",
+    ],
+)
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
new file mode 100644
index 0000000..c9a5d9b
--- /dev/null
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -0,0 +1,263 @@
+# Gerrit JavaScript style guide
+
+Gerrit frontend follows [recommended eslint rules](https://eslint.org/docs/rules/)
+and [Google JavaScript Style Guide](https://google.github.io/styleguide/jsguide.html).
+Eslint is used to automate rules checking where possible. You can find exact eslint rules
+[here](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/.eslintrc.js).
+
+Gerrit JavaScript code uses ES6 modules and doesn't use goog.module files.
+
+Additionally to the rules above, Gerrit frontend uses the following rules (some of them have automated checks,
+some don't):
+
+- [Use destructuring imports only](#destructuring-imports-only)
+- [Use classes and services for storing and manipulating global state](#services-for-global-state)
+- [Pass required services in the constructor for plain classes](#pass-dependencies-in-constructor)
+- [Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
+
+## <a name="destructuring-imports-only"></a>Use destructuring imports only
+Always use destructuring import statement and specify all required names explicitly (e.g. `import {a,b,c} from '...'`)
+where possible.
+
+**Note:** Destructuring imports are not always possible with 3rd-party libraries, because a 3rd-party library
+can expose a class/function/const/etc... as a default export. In this situation you can use default import, but please
+keep consistent naming across the whole gerrit project. The best way to keep consistency is to search across our
+codebase for the same import. If you find an exact match - always use the same name for your import. If you can't
+find exact matches - find a similar import and assign appropriate/similar name for your default import. Usually the
+name should include a library name and part of the file path.
+
+You can read more about different type of imports
+[here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import).
+
+**Good:**
+```Javascript
+// Import from the module in the same project.
+import {getDisplayName, getAccount} from './user-utils.js'
+
+// The following default import is allowed only for 3rd-party libraries.
+// Please ensure, that all imports have the same name accross gerrit project (downloadImage in this example)
+import downloadImage from 'third-party-library/images/download.js'
+```
+
+**Bad:**
+```Javascript
+import * as userUtils from './user-utils.js'
+```
+
+## <a name="services-for-global-state"></a>Use classes and services for storing and manipulating global state
+
+You must use classes and services to share global state across the gerrit frontend code. Do not put a state at the
+top level of a module.
+
+It is not easy to define precise what can be a shared global state and what is not. Below are some
+examples of what can treated as a shared global state:
+
+* Information about enabled experiments
+* Information about current user
+* Information about current change
+
+**Note:**
+
+Service name must ends with a `Service` suffix.
+
+To share global state across modules in the project, do the following:
+- put the state in a class
+- add a new service to the
+[appContext](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/services/app-context.js)
+- add a service initialization code to the
+[services/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/services/app-context-init.js) file.
+- add a service or service-mock initialization code to the
+[embed/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/embed/app-context-init.js) file.
+- recommended: add a separate service-mock for testing. Do not use the same mock for testing and for
+the shared gr-diff (i.e. in the `services/app-context-init.js`). Even if the mocks are simple and looks
+identically, keep them separate. It allows to change them independently in the future.
+
+Also see the example below if a service depends on another services.
+
+**Note 1:** Be carefull with the shared gr-diff element. If a service is not required for the shared gr-diff,
+the safest option is to provide a mock for this service in the embed/app-context-init.js file. In exceptional
+cases you can keep the service uninitialized in
+[embed/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/embed/app-context-init.js) file
+, but it is recommended to write a comment why mocking is not possible. In the future we can
+review/update rules regarding the shared gr-diff element.
+
+**Good:**
+```Javascript
+export class CounterService {
+    constructor() {
+        this._count = 0;
+    }
+    get count() {
+        return this._count;
+    }
+    inc() {
+        this._count++;
+    }
+}
+
+// app-context.js
+export const appContext = {
+    //...
+    mouseClickCounterService: null,
+    keypressCounterService: null,
+};
+
+// services/app-context-init.js
+export function initAppContext() {
+    //...
+    // Add the following line before the Object.defineProperties(appContext, registeredServices);
+    addService('mouseClickCounterService', () => new CounterService());
+    addService('keypressCounterService', () => new CounterService());
+    // If a service depends on other services, pass dependencies as shown below
+    // If circular dependencies exist, app-init-context tests fail with timeout or stack overflow
+    // (we are  going to improve it in the future)
+    addService('analyticService', () =>
+        new CounterService(appContext.mouseClickCounterService, appContext.keypressCounterService));
+    //...
+    // This following line must remains the last one in the initAppContext
+    Object.defineProperties(appContext, registeredServices);
+}
+```
+
+**Bad:**
+```Javascript
+// module counter.js
+// Incorrect: shared state declared at the top level of the counter.js module
+let count = 0;
+export function getCount() {
+    return count;
+}
+export function incCount() {
+    count++;
+}
+```
+
+## <a name="pass-dependencies-in-constructor"></a>Pass required services in the constructor for plain classes
+
+If a class/service depends on some other service (or multiple services), the class must accept all dependencies
+as parameters in the constructor.
+
+Do not use appContext anywhere else in a class.
+
+**Note:** This rule doesn't apply for HTML/Polymer elements classes. A browser creates instances of such classes
+implicitly and calls the constructor without parameters. See
+[Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
+
+**Good:**
+```Javascript
+export class UserService {
+    constructor(restApiService) {
+        this._restApiService = restApiService;
+    }
+    getLoggedIn() {
+        // Send request to server using this._restApiService
+    }
+}
+```
+
+**Bad:**
+```Javascript
+import {appContext} from "./app-context";
+
+export class UserService {
+    constructor() {
+        // Incorrect: you must pass all dependencies to a constructor
+        this._restApiService = appContext.restApiService;
+    }
+}
+
+export class AdminService {
+    isAdmin() {
+        // Incorrect: you must pass all dependencies to a constructor
+        return appContext.restApiService.sendRequest(...);
+    }
+}
+
+```
+
+## <a name="assign-dependencies-in-html-element-constructor"></a>Assign required services in a HTML/Polymer element constructor
+If a class is a custom HTML/Polymer element, the class must assign all required services in the constructor.
+A browser creates instances of such classes implicitly, so it is impossible to pass anything as a parameter to
+the element's class constructor.
+
+Do not use appContext anywhere except the constructor of the class.
+
+**Note for legacy elements:** If a polymer element extends a LegacyElementMixin and overrides the `created()` method,
+move all code from this method to a constructor right after the call to a `super()`
+([example](#assign-dependencies-legacy-element-example)). The `created()`
+method is [deprecated](https://polymer-library.polymer-project.org/2.0/docs/about_20#lifecycle-changes) and is called
+when a super (i.e. base) class constructor is called. If you are unsure about moving the code from the `created` method
+to the class constructor, consult with the source code:
+[`LegacyElementMixin._initializeProperties`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/legacy/legacy-element-mixin.js#L318)
+and
+[`PropertiesChanged.constructor`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/mixins/properties-changed.js#L177)
+
+
+
+**Good:**
+```Javascript
+import {appContext} from `.../services/app-context.js`;
+
+export class MyCustomElement extends ...{
+    constructor() {
+        super(); //This is mandatory to call parent constructor
+        this._userService = appContext.userService;
+    }
+    //...
+    _getUserName() {
+        return this._userService.activeUserName();
+    }
+}
+```
+
+**Bad:**
+```Javascript
+import {appContext} from `.../services/app-context.js`;
+
+export class MyCustomElement extends ...{
+    created() {
+        // Incorrect: assign all dependencies in the constructor
+        this._userService = appContext.userService;
+    }
+    //...
+    _getUserName() {
+        // Incorrect: use appContext outside of a constructor
+        return appContext.userService.activeUserName();
+    }
+}
+```
+
+<a name="assign-dependencies-legacy-element-example"></a>
+**Legacy element:**
+
+Before:
+```Javascript
+export class MyCustomElement extends ...LegacyElementMixin(...) {
+    constructor() {
+        super();
+        someAction();
+    }
+    created() {
+        super();
+        createdAction1();
+        createdAction2();
+    }
+}
+```
+
+After:
+```Javascript
+export class MyCustomElement extends ...LegacyElementMixin(...) {
+    constructor() {
+        super();
+        // Assign services here
+        this._userService = appContext.userService;
+        // Code from the created method - put it before existing actions in constructor
+        createdAction1();
+        createdAction2();
+        // Original constructor code
+        someAction();
+    }
+    // created method is removed
+}
+```
diff --git a/polygerrit-ui/Polymer3.md b/polygerrit-ui/Polymer3.md
index 94750d8..186f0f4 100644
--- a/polygerrit-ui/Polymer3.md
+++ b/polygerrit-ui/Polymer3.md
@@ -14,6 +14,11 @@
 
 To get inspirations, check out our [samples here](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples).
 
+### Plugin dependencies
+
+Since most of Gerrit plugins are treated as sub modules and part of the Gerrit workspace when develop, dependencies of plugins are also defined and installed from Gerrit WORKSPACE, currently most of them are `bower_archives`. When moving to npm, if your plugin requires dependencies, you can have them added to your plugin's `package.json` and then link that file to `plugins/package.json` in gerrit.
+Then use `@plugins_npm//:node_modules` to make sure `rollup_bundle` knows the right place to look for. More examples from `image-diff` plugin, [change 271672](https://gerrit-review.googlesource.com/c/plugins/image-diff/+/271672).
+
 ### Related resources
 
 - [Polymer 3.0 upgrade guide](https://polymer-library.polymer-project.org/3.0/docs/upgrade)
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 1cd8096..41ee468 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -74,6 +74,28 @@
 
 More information for installing and using nodejs rules can be found here https://bazelbuild.github.io/rules_nodejs/install.html
 
+### Upgrade to @bazel-scoped packages
+
+It might be necessary to run this command to upgrade to major `rules_nodejs` release:
+
+```sh
+yarn remove @bazel/...
+```
+
+## Setup typescript support in the IDE
+
+Modern IDE should automatically handle typescript settings from the 
+`pollygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
+`.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
+to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
+this directory and select "Mark Directory As > Excluded" in the context menu.
+
+However, if you receive some errors from IDE, you can try to configure IDE
+manually. For example, if IntelliJ IDEA shows
+`Cannot find parent 'tsconfig.json'` error, you can try to setup typescript
+options `--project polygerrit-ui/app/tsconfig.json` in the IDE settings.
+
+
 ## Serving files locally
 
 #### Go server
@@ -148,29 +170,61 @@
 ## Running Tests
 
 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>.
-Check "Disable cache" in the "Network" tab of Chrome's dev tools, so code
-changes are picked up on "reload".
+There are several ways to run tests.
 
-Our CI integration ensures that all tests are run when you upload a change to
-Gerrit, but you can also run all tests locally in headless mode:
-
+* Run all tests in headless mode (exactly like CI does):
 ```sh
-npm test
+npm run test
+```
+This command uses bazel rules for running frontend tests. Bazel fetches
+all nessecary dependencies and runs all required rules.
+
+* Run all tests in debug mode (the command opens Chrome browser with
+the default Karma page; you should click the "Debug" button to start testing):
+```sh
+# The following command doesn't compile code before tests
+npm run test:debug
 ```
 
-To allow the tests to run in Safari:
+* Run a single test file:
+```
+# Headless mode (doesn't compile code before run)
+npm run test:single async-foreach-behavior_test.js
 
-* In the Advanced preferences tab, check "Show Develop menu in menu bar".
-* In the Develop menu, enable the "Allow Remote Automation" option.
+# Debug mode (doesn't compile code before run)
+npm run test:debug async-foreach-behavior_test.js
+```
 
-To run Chrome tests in headless mode:
+Commands `test:debug` and `test:single` assumes that compiled code is located
+in the `./ts-out/polygerrit-ui/app` directory. It's up to you how to achieve it.
+For example, the following options are possible:
+* You can configure IDE for recompiling source code on changes
+* You can use `compile:local` command for running compiler once and
+`compile:watch` for running compiler in watch mode (`compile:...` places
+compile code exactly in the `./ts-out/polygerrit-ui/app` directory)
 
 ```sh
-WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh
+# Compile frontend once and run tests from a file:
+npm run compile:local && npm run test:single async-foreach-behavior_test.js
+
+# Watch mode:
+## Terminal 1:
+npm run compile:watch
+## Terminal 2:
+npm run test:debug async-foreach-behavior_test.js
 ```
 
+* You can run tests in IDE. :
+  - [IntelliJ: running unit tests on Karma](https://www.jetbrains.com/help/idea/running-unit-tests-on-karma.html#ws_karma_running)
+  - You should configure IDE to compile typescript before running tests.
+
+**NOTE**: Bazel plugin for IntelliJ has a bug - it recompiles typescript
+project only if .ts and/or .d.ts files have been changed. If only .js files
+were changed, the plugin doesn't run compiler. As a workaround, setup
+"Run npm script 'compile:local" action instead of the "Compile Typescript" in
+the "Before launch" section for IntelliJ. This is a temporary problem until
+typescript migration is complete.
+
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
@@ -226,6 +280,245 @@
 npm run polylint
 ```
 
+## Migrating tests to Typescript
+
+You can use the following steps for migrating tests to Typescript:
+
+1. Rename the `_test.js` file to `_test.ts`
+2. Remove `.js` extensions from all imports:
+   ```
+   // Before:
+   import ... from 'x/y/z.js`
+ 
+   // After
+   import .. from 'x/y/z'
+   ```
+3. Fix typescript and eslint errors.
+
+Common errors and fixes are:
+
+* An object in the test doesn't have all required properties. You can use
+existing helpers to create an object with all required properties:
+```
+// Before:
+sinon.stub(element.$.restAPI, 'getPreferences').returns(
+    Promise.resolve({default_diff_view: 'UNIFIED'}));
+
+// After:
+Promise.resolve({
+  ...createPreferences(),
+  default_diff_view: DiffViewMode.UNIFIED,
+})
+```
+
+Some helpers receive parameters:
+```
+// Before
+element._change = {
+  change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+  revisions: {
+    rev1: {_number: 1, commit: {parents: []}},
+    rev2: {_number: 2, commit: {parents: []}},
+  },
+  current_revision: 'rev1',
+  status: ChangeStatus.MERGED,
+  labels: {},
+  actions: {},
+};
+
+// After
+element._change = {
+  ...createChange(),
+  // The change_id is set by createChange.
+  // The exact change_id is not important in the test, so it was removed.
+  revisions: {
+    rev1: createRevision(1), // _number is a parameter here
+    rev2: createRevision(2), // _number is a parameter here
+  },
+  current_revision: 'rev1' as CommitId,
+  status: ChangeStatus.MERGED,
+  labels: {},
+  actions: {},
+};
+```
+* Typescript reports some weird messages about `window` property - sometimes an
+IDE adds wrong import. Just remove it.
+```
+// The wrong import added by IDE, must be removed
+import window = Mocha.reporters.Base.window;
+```
+
+* `TS2531: Object is possibly 'null'`. To fix use either non-null assertion
+operator `!` or nullish coalescing operator `?.`:
+```
+// Before:
+const rows = element
+  .shadowRoot.querySelector('table')
+  .querySelectorAll('tbody tr');
+...
+// The _robotCommentThreads declared as _robotCommentThreads?: CommentThread
+assert.equal(element._robotCommentThreads.length, 2);
+  
+// Fix with non-null assertion operator:
+const rows = element
+  .shadowRoot!.querySelector('table')! // '!' after shadowRoot and querySelector
+  .querySelectorAll('tbody tr');
+
+assert.equal(element._robotCommentThreads!.length, 2); 
+
+// Fix with nullish coalescing operator:
+ assert.equal(element._robotCommentThreads?.length, 2); 
+```
+Usually the fix with `!` is preferable, because it gives more clear error
+when an intermediate property is `null/undefined`. If the _robotComments is
+`undefined` in the example above, the `element._robotCommentThreads!.length`
+crashes with the error `Cannot read property 'length' of undefined`. At the
+same time the fix with
+`?.` doesn't distinct between 2 cases: _robotCommentThreads is `undefined`
+and `length` is `undefined`.
+
+* `TS2339: Property '...' does not exist on type 'Element'.` for elements
+returned by `querySelector/querySelectorAll`. To fix it, use generic versions
+of those methods:
+```
+// Before:
+const radios = parentTable
+  .querySelectorAll('input[type=radio]');
+const radio = parentRow
+  .querySelector('input[type=radio]');
+
+// After:
+const radios = parentTable
+  .querySelectorAll<HTMLInputElement>('input[type=radio]');
+const radio = parentRow
+  .querySelector<HTMLInputElement>('input[type=radio]');
+```
+
+* Sinon: `TS2339: Property 'lastCall' does not exist on type '...` (the same
+for other sinon properties). Store stub/spy in a variable and then use the
+variable:
+```
+// Before:
+sinon.stub(GerritNav, 'getUrlForChange')
+...
+assert.equal(GerritNav.getUrlForChange.lastCall.args[4], '#message-a12345');
+
+// After:
+const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
+...
+assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+```
+
+If you need to define a type for such variable, you can use one of the following
+options:
+```
+suite('my suite', () => {
+    // Non static members, option 1
+    let updateHeightSpy: SinonSpyMember<typeof element._updateRelatedChangeMaxHeight>;
+    // Non static members, option 2
+    let updateHeightSpy_prototype: SinonSpyMember<typeof GrChangeView.prototype._updateRelatedChangeMaxHeight>;
+    // Static members
+    let navigateToChangeStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+    // For interfaces
+    let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
+});
+```
+
+* Typescript reports errors when stubbing/faking methods:
+```
+// The JS code:
+const reloadStub = sinon
+    .stub(element, '_reload')
+    .callsFake(() => Promise.resolve());
+
+stub('gr-rest-api-interface', {
+  getDiffComments() { return Promise.resolve({}); },
+  getDiffRobotComments() { return Promise.resolve({}); },
+  getDiffDrafts() { return Promise.resolve({}); },
+  _fetchSharedCacheURL() { return Promise.resolve({}); },
+});
+```
+
+In such cases, validate the input and output of a stub/fake method. Quite often
+tests return null instead of undefined or `[]` instead of `{}`, etc...
+Fix types if they are not correct:
+```
+const reloadStub = sinon
+  .stub(element, '_reload')
+  // GrChangeView._reload method returns an array
+  .callsFake(() => Promise.resolve([])); // return [] here
+
+stub('gr-rest-api-interface', {
+  ...
+  // Fix return type:
+  _fetchSharedCacheURL() { return Promise.resolve({} as ParsedJSON); },
+});
+```
+
+If a method has multiple overloads, you can use one of 2 options:
+```
+// Option 1: less accurate, but shorter:
+function getCommentsStub() {
+  return Promise.resolve({});
+}
+
+stub('gr-rest-api-interface', {
+  ...
+  getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
+  getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
+  getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
+  ...
+});
+
+// Option 2: more accurate, but longer.
+// Step 1: define the same overloads for stub:
+function getDiffCommentsStub(
+  changeNum: NumericChangeId
+): Promise<PathToCommentsInfoMap | undefined>;
+function getDiffCommentsStub(
+  changeNum: NumericChangeId,
+  basePatchNum: PatchSetNum,
+  patchNum: PatchSetNum,
+  path: string
+): Promise<GetDiffCommentsOutput>;
+
+// Step 2: implement stub method for differnt input
+function getDiffCommentsStub(
+  _: NumericChangeId,
+  basePatchNum?: PatchSetNum,
+):
+  | Promise<PathToCommentsInfoMap | undefined>
+  | Promise<GetDiffCommentsOutput> {
+  if (basePatchNum) {
+    return Promise.resolve({
+      baseComments: [],
+      comments: [],
+    });
+  }
+  return Promise.resolve({});
+}
+
+// Step 3: use stubbed function:
+stub('gr-rest-api-interface', {
+  ...
+  getDiffComments: getDiffCommentsStub,
+  ...
+});
+```
+
+* If a test requires a `@types/...` library, install the required library
+in the `polygerrit_ui/node_modules` and update the `typeRoots` in the
+`polygerrit-ui/app/tsconfig_bazel_test.json` file.
+
+The same update should be done if a test requires a .d.ts file from a library
+that already exists in `polygerrit_ui/node_modules`.
+
+**Note:** Types from a library located in `polygerrit_ui/app/node_modules` are
+handle automatically.
+
+* If a test imports a library from `polygerrit_ui/node_modules` - update
+`paths` in `polygerrit-ui/app/tsconfig_bazel_test.json`.
+ 
 ## Contributing
 
 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).
@@ -258,4 +551,4 @@
 
 // install all dependencies and start the server
 npm start
-```
\ No newline at end of file
+```
diff --git a/polygerrit-ui/app/.eslint-ts-resolver.js b/polygerrit-ui/app/.eslint-ts-resolver.js
new file mode 100644
index 0000000..dc578f9
--- /dev/null
+++ b/polygerrit-ui/app/.eslint-ts-resolver.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.
+ */
+
+/**
+ * This is a very simple resolver for the 'js imports ts' case. It is used only
+ * by eslint and must be removed after switching to typescript is finished.
+ * The resolver searches for .ts files instead of .js
+ */
+
+const path = require('path');
+const fs = require('fs');
+
+function isRelativeImport(source) {
+  return source.startsWith('./') || source.startsWith('../');
+}
+
+module.exports = {
+  interfaceVersion: 2,
+  resolve: function(source, file, config) {
+    if (!isRelativeImport(source) || !source.endsWith('.js')) {
+      return {found: false};
+    }
+    const tsSource = source.slice(0, -3) + '.ts';
+
+    const fullPath = path.resolve(path.dirname(file), tsSource);
+    if (!fs.existsSync(fullPath)) {
+      return {found: false};
+    }
+    return {found: true, path: fullPath};
+  }
+};
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index 6d9c8f3..16ea228 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -1,2 +1,3 @@
 **/node_modules
 **/rollup.config.js
+node_modules_licenses
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 42a6564..c5dde38 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -17,6 +17,7 @@
 
 // Do not add any bazel-specific properties in this file to keep it clean.
 // Please add such properties to the .eslintrc-bazel.js file
+const path = require('path');
 
 module.exports = {
   "extends": ["eslint:recommended", "google"],
@@ -29,14 +30,22 @@
     "es6": true
   },
   "rules": {
+    // https://eslint.org/docs/rules/no-confusing-arrow
     "no-confusing-arrow": "error",
+    // https://eslint.org/docs/rules/newline-per-chained-call
     "newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}],
+    // https://eslint.org/docs/rules/arrow-body-style
     "arrow-body-style": ["error", "as-needed",
       {"requireReturnForObjectLiteral": true}],
+    // https://eslint.org/docs/rules/arrow-parens
     "arrow-parens": ["error", "as-needed"],
+    // https://eslint.org/docs/rules/block-spacing
     "block-spacing": ["error", "always"],
+    // https://eslint.org/docs/rules/brace-style
     "brace-style": ["error", "1tbs", {"allowSingleLine": true}],
+    // https://eslint.org/docs/rules/camelcase
     "camelcase": "off",
+    // https://eslint.org/docs/rules/comma-dangle
     "comma-dangle": ["error", {
       "arrays": "always-multiline",
       "objects": "always-multiline",
@@ -44,7 +53,9 @@
       "exports": "always-multiline",
       "functions": "never"
     }],
+    // https://eslint.org/docs/rules/eol-last
     "eol-last": "off",
+    // https://eslint.org/docs/rules/indent
     "indent": ["error", 2, {
       "MemberExpression": 2,
       "FunctionDeclaration": {"body": 1, "parameters": 2},
@@ -54,8 +65,11 @@
       "ObjectExpression": 1,
       "SwitchCase": 1
     }],
+    // https://eslint.org/docs/rules/keyword-spacing
     "keyword-spacing": ["error", {"after": true, "before": true}],
+    // https://eslint.org/docs/rules/lines-between-class-members
     "lines-between-class-members": ["error", "always"],
+    // https://eslint.org/docs/rules/max-len
     "max-len": [
       "error",
       80,
@@ -65,14 +79,26 @@
         "ignorePattern": "^import .*;$"
       }
     ],
+    // https://eslint.org/docs/rules/new-cap
     "new-cap": ["error", {
-      "capIsNewExceptions": ["Polymer", "LegacyElementMixin",
-        "GestureEventListeners", "LegacyDataMixin"]
+      "capIsNewExceptions": ["Polymer", "GestureEventListeners"],
+      "capIsNewExceptionPattern": "^.*Mixin$"
     }],
-    "no-console": "off",
+    // https://eslint.org/docs/rules/no-console
+    "no-console": ["error", { allow: ["warn", "error", "info", "assert", "group", "groupEnd"] }],
+    // https://eslint.org/docs/rules/no-multiple-empty-lines
     "no-multiple-empty-lines": ["error", {"max": 1}],
+    // https://eslint.org/docs/rules/no-prototype-builtins
     "no-prototype-builtins": "off",
+    // https://eslint.org/docs/rules/no-redeclare
     "no-redeclare": "off",
+    // https://eslint.org/docs/rules/no-trailing-spaces
+    "no-trailing-spaces": "error",
+    // https://eslint.org/docs/rules/no-irregular-whitespace
+    "no-irregular-whitespace": "error",
+    // https://eslint.org/docs/rules/array-callback-return
+    "array-callback-return": ['error', { allowImplicit: true }],
+    // https://eslint.org/docs/rules/no-restricted-syntax
     "no-restricted-syntax": [
       "error",
       {
@@ -86,11 +112,17 @@
     ],
     // no-undef disables global variable.
     // "globals" declares allowed global variables.
+    // https://eslint.org/docs/rules/no-undef
     "no-undef": ["error"],
+    // https://eslint.org/docs/rules/no-useless-escape
     "no-useless-escape": "off",
+    // https://eslint.org/docs/rules/no-var
     "no-var": "error",
+    // https://eslint.org/docs/rules/operator-linebreak
     "operator-linebreak": "off",
+    // https://eslint.org/docs/rules/object-shorthand
     "object-shorthand": ["error", "always"],
+    // https://eslint.org/docs/rules/padding-line-between-statements
     "padding-line-between-statements": [
       "error",
       {
@@ -104,42 +136,76 @@
         "next": "class"
       }
     ],
+    // https://eslint.org/docs/rules/prefer-arrow-callback
     "prefer-arrow-callback": "error",
+    // https://eslint.org/docs/rules/prefer-const
     "prefer-const": "error",
+    // https://eslint.org/docs/rules/prefer-promise-reject-errors
     "prefer-promise-reject-errors": "error",
+    // https://eslint.org/docs/rules/prefer-spread
     "prefer-spread": "error",
+    // https://eslint.org/docs/rules/prefer-object-spread
+    "prefer-object-spread": "error",
+    // https://eslint.org/docs/rules/quote-props
     "quote-props": ["error", "consistent-as-needed"],
-    "semi": [2, "always"],
+    // https://eslint.org/docs/rules/semi
+    "semi": ["error", "always"],
+    // https://eslint.org/docs/rules/template-curly-spacing
     "template-curly-spacing": "error",
 
+    // https://eslint.org/docs/rules/require-jsdoc
     "require-jsdoc": 0,
+    // https://eslint.org/docs/rules/valid-jsdoc
     "valid-jsdoc": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-alignment
     "jsdoc/check-alignment": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-examples
     "jsdoc/check-examples": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-indentation
     "jsdoc/check-indentation": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-param-names
     "jsdoc/check-param-names": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-syntax
     "jsdoc/check-syntax": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names
     "jsdoc/check-tag-names": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types
     "jsdoc/check-types": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-implements-on-classes
     "jsdoc/implements-on-classes": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
     "jsdoc/match-description": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
     "jsdoc/newline-after-description": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
     "jsdoc/no-types": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
     "jsdoc/no-undefined-types": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description
     "jsdoc/require-description": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description-complete-sentence
     "jsdoc/require-description-complete-sentence": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-example
     "jsdoc/require-example": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-hyphen-before-param-description
     "jsdoc/require-hyphen-before-param-description": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-jsdoc
     "jsdoc/require-jsdoc": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param
     "jsdoc/require-param": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-description
     "jsdoc/require-param-description": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-name
     "jsdoc/require-param-name": 2,
-    "jsdoc/require-param-type": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns
     "jsdoc/require-returns": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-check
     "jsdoc/require-returns-check": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-description
     "jsdoc/require-returns-description": 0,
-    "jsdoc/require-returns-type": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-valid-types
     "jsdoc/valid-types": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-file-overview
     "jsdoc/require-file-overview": ["error", {
       "tags": {
         "license": {
@@ -148,15 +214,21 @@
         }
       }
     }],
-    "import/named": 2,
-    "import/no-unresolved": 2,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-self-import.md
     "import/no-self-import": 2,
     // The no-cycle rule is slow, because it doesn't cache dependencies.
     // Disable it.
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-cycle.md
     "import/no-cycle": 0,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-useless-path-segments.md
     "import/no-useless-path-segments": 2,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-unused-modules.md
     "import/no-unused-modules": 2,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
     "import/no-default-export": 2,
+    // Prevents certain identifiers being used.
+    // Prefer flush() over flushAsynchronousOperations().
+    "id-blacklist": ["error", "flushAsynchronousOperations"],
   },
 
   // List of allowed globals in all files
@@ -165,24 +237,74 @@
     // 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": [
     {
+      // .js-only rules
+      "files": ["**/*.js"],
+      "rules": {
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-type
+        "jsdoc/require-param-type": 2,
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
+        "jsdoc/require-returns-type": 2,
+        // The rule is required for .js files only, because typescript compiler
+        // always checks import.
+        "import/no-unresolved": 2,
+        "import/named": 2,
+      },
+      "globals": {
+        "goog": "readonly",
+      }
+    },
+    {
+      "files": ["**/*.ts"],
+      "extends": [require.resolve("gts/.eslintrc.json")],
+      "rules": {
+        "no-restricted-imports": ["error", {
+          name: "@polymer/decorators/lib/decorators",
+          message: "Use @polymer/decorators instead",
+        }],
+        // See https://github.com/GoogleChromeLabs/shadow-selection-polyfill/issues/9
+        "@typescript-eslint/ban-ts-ignore": "off",
+        // The following rules is required to match internal google rules
+        "@typescript-eslint/restrict-plus-operands": "error",
+        // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
+        "node/no-unsupported-features/node-builtins": "off",
+        // Disable no-invalid-this for ts files, because it incorrectly reports
+        // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
+        // At the same time, we are using typescript in a strict mode and
+        // it catches almost all errors related to invalid usage of this.
+        "no-invalid-this": "off",
+
+        "node/no-extraneous-import": "off",
+
+        // Typescript already checks for undef
+        "no-undef": "off",
+
+        "jsdoc/no-types": 2,
+      },
+      "parserOptions": {
+        "project": path.resolve(__dirname, "./tsconfig_eslint.json"),
+      }
+    },
+    {
       "files": ["*.html", "test.js", "test-infra.js"],
       "rules": {
         "jsdoc/require-file-overview": "off"
       },
     },
     {
-      "files": ["*.html", "common-test-setup.js"],
+      "files": [
+        "*.html",
+        "*_test.js",
+        "a11y-test-utils.js",
+      ],
       // Additional global variables allowed in tests
       "globals": {
         // Global variables from 3rd party test libraries/frameworks.
@@ -190,6 +312,7 @@
         // variables from these libraries and import is not possible
         "MockInteractions": "readonly",
         "_": "readonly",
+        "axs": "readonly",
         "a11ySuite": "readonly",
         "assert": "readonly",
         "expect": "readonly",
@@ -201,8 +324,11 @@
         "stub": "readonly",
         "suite": "readonly",
         "suiteSetup": "readonly",
+        "suiteTeardown": "readonly",
         "teardown": "readonly",
         "test": "readonly",
+        "fixtureFromElement": "readonly",
+        "fixtureFromTemplate": "readonly",
       }
     },
     {
@@ -212,14 +338,15 @@
       }
     },
     {
-      "files": ["samples/**/*.js", "**/test/plugin.html"],
+      "files": ["samples/**/*.js"],
       "globals": {
         // Settings for samples. You can add globals here if you want to use it
         "Gerrit": "readonly",
+        "Polymer": "readonly",
       }
     },
     {
-      "files": ["test/functional/**/*.js", "wct.conf.js"],
+      "files": ["test/functional/**/*.js"],
       // Settings for functional tests. These scripts are node scripts.
       // Turn off "no-undef" to allow any global variable
       "env": {
@@ -232,12 +359,6 @@
       }
     },
     {
-      "files": "test/index.html",
-      "globals": {
-        "WCT": "readonly",
-      }
-    },
-    {
       "files": ["*_html.js", "gr-icons.js", "*-theme.js", "*-styles.js"],
       "rules": {
         "max-len": "off"
@@ -260,6 +381,10 @@
     "prettier"
   ],
   "settings": {
-    "html/report-bad-indent": "error"
+    "html/report-bad-indent": "error",
+    "import/resolver": {
+      "node": {},
+      [path.resolve(__dirname, './.eslint-ts-resolver.js')]: {},
+    },
   },
 };
diff --git a/polygerrit-ui/app/.prettierrc.js b/polygerrit-ui/app/.prettierrc.js
new file mode 100644
index 0000000..fbb87c6
--- /dev/null
+++ b/polygerrit-ui/app/.prettierrc.js
@@ -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.
+ */
+
+module.exports = {
+  "overrides": [
+    {
+      "files": ["**/*.ts"],
+      "options": {
+          ...require('gts/.prettierrc.json')
+      }
+    }
+  ]
+};
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 5a3f140..c29663c 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,24 +1,86 @@
-load(":rules.bzl", "polygerrit_bundle", "wct_suite")
+load(":rules.bzl", "compile_ts", "polygerrit_bundle")
 load("//tools/js:eslint.bzl", "eslint")
 
 package(default_visibility = ["//visibility:public"])
 
-polygerrit_bundle(
-    name = "polygerrit_ui",
+# This list must be in sync with the "include" list in the follwoing files:
+# tsconfig.json, tsconfig_bazel.json, tsconfig_bazel_test.json
+src_dirs = [
+    "constants",
+    "elements",
+    "embed",
+    "gr-diff",
+    "mixins",
+    "samples",
+    "scripts",
+    "services",
+    "styles",
+    "types",
+    "utils",
+]
+
+compiled_pg_srcs = compile_ts(
+    name = "compile_pg",
+    srcs = glob(
+        [src_dir + "/**/*" + ext for src_dir in src_dirs for ext in [
+            ".js",
+            ".ts",
+        ]],
+        exclude = [
+            "**/*_test.js",
+            "**/*_test.ts",
+        ],
+    ),
+    # The same outdir also appears in the following files:
+    # polylint_test.sh
+    ts_outdir = "_pg_ts_out",
+)
+
+compiled_pg_srcs_with_tests = compile_ts(
+    name = "compile_pg_with_tests",
     srcs = glob(
         [
             "**/*.js",
+            "**/*.ts",
+            "test/@types/*.d.ts",
         ],
         exclude = [
             "node_modules/**",
             "node_modules_licenses/**",
-            "test/**",
-            "**/*_test.html",
-            "**/*_test.js",
+            "template_test_srcs/**",
+            "rollup.config.js",
         ],
     ),
+    include_tests = True,
+    # The same outdir also appears in the following files:
+    # wct_test.sh
+    # karma.conf.js
+    ts_outdir = "_pg_with_tests_out",
+)
+
+polygerrit_bundle(
+    name = "polygerrit_ui",
+    srcs = compiled_pg_srcs,
     outs = ["polygerrit_ui.zip"],
-    entry_point = "elements/gr-app.html",
+    entry_point = "_pg_ts_out/elements/gr-app.js",
+)
+
+filegroup(
+    name = "eslint_src_code",
+    srcs = glob(
+        [
+            "**/*.html",
+            "**/*.js",
+            "**/*.ts",
+        ],
+        exclude = [
+            "node_modules/**",
+            "node_modules_licenses/**",
+        ],
+    ) + [
+        "@ui_dev_npm//:node_modules",
+        "@ui_npm//:node_modules",
+    ],
 )
 
 filegroup(
@@ -26,62 +88,44 @@
     srcs = glob(
         [
             "**/*.html",
-            "**/*.js",
         ],
         exclude = [
             "node_modules/**",
             "node_modules_licenses/**",
         ],
-    ),
-)
-
-filegroup(
-    name = "pg_code_without_test",
-    srcs = glob(
-        [
-            "**/*.html",
-            "**/*.js",
-        ],
-        exclude = [
-            "node_modules/**",
-            "node_modules_licenses/**",
-            "**/*_test.html",
-            "test/**",
-            "samples/**",
-            "**/*_test.js",
-        ],
-    ),
+    ) + compiled_pg_srcs_with_tests,
 )
 
 # Workaround for https://github.com/bazelbuild/bazel/issues/1305
 filegroup(
     name = "test-srcs-fg",
     srcs = [
-        "test/common-test-setup.js",
-        "test/index.html",
+        "rollup.config.js",
         ":pg_code",
         "@ui_dev_npm//:node_modules",
         "@ui_npm//:node_modules",
     ],
 )
 
-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"],
+    srcs = [":eslint_src_code"],
     config = ".eslintrc-bazel.js",
-    # The .eslintrc-bazel.js extends the .eslintrc.js config, pass it as a dependency
-    data = [".eslintrc.js"],
+    data = [
+        # The .eslintrc-bazel.js extends the .eslintrc.js config, pass it as a dependency
+        ".eslintrc.js",
+        ".prettierrc.js",
+        ".eslint-ts-resolver.js",
+        "tsconfig_eslint.json",
+        # tsconfig_eslint.json extends tsconfig.json, pass it as a dependency
+        "tsconfig.json",
+    ],
     extensions = [
         ".html",
         ".js",
+        ".ts",
     ],
     ignore = ".eslintignore",
     plugins = [
@@ -90,16 +134,18 @@
         "@npm//eslint-plugin-import",
         "@npm//eslint-plugin-jsdoc",
         "@npm//eslint-plugin-prettier",
+        "@npm//gts",
     ],
 )
 
-# Workaround for https://github.com/bazelbuild/bazel/issues/1305
 filegroup(
     name = "polylint-fg",
     srcs = [
-        ":pg_code_without_test",
+        # Workaround for https://github.com/bazelbuild/bazel/issues/1305
         "@ui_npm//:node_modules",
-    ],
+    ] +
+    # Polylinter can't check .ts files, run it on compiled srcs
+    compiled_pg_srcs,
 )
 
 sh_test(
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
deleted file mode 100644
index 1d384bc..0000000
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
+++ /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.
- */
-
-/** @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
deleted file mode 100644
index 1d50cc4..0000000
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
+++ /dev/null
@@ -1,56 +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">
-<meta charset="utf-8">
-<title>async-foreach-behavior</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 {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.js b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js
deleted file mode 100644
index 4deb089..0000000
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js
+++ /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.
- */
-
-/** @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
deleted file mode 100644
index 61d7bac..0000000
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ /dev/null
@@ -1,74 +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">
-<meta charset="utf-8">
-<title>base-url-behavior</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';
-/** @type {string} */
-window.CANONICAL_PATH = '/r';
-</script>
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<test-fixture id="within-overlay">
-  <template>
-    <gr-overlay>
-      <test-element></test-element>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<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: [
-        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.js b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js
deleted file mode 100644
index add1df4..0000000
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js
+++ /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.
- */
-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
deleted file mode 100644
index 0efd80f..0000000
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
+++ /dev/null
@@ -1,99 +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.
--->
-<!-- Polymer included for the html import polyfill. -->
-<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="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <docs-url-behavior-element></docs-url-behavior-element>
-  </template>
-</test-fixture>
-
-<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: [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.js b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
deleted file mode 100644
index b8d54a4..0000000
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
+++ /dev/null
@@ -1,46 +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.
- */
-
-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
deleted file mode 100644
index 1e842c1..0000000
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
+++ /dev/null
@@ -1,72 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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>
-    <div>
-      <div class="a">
-        <div class="b">
-          <div class="c"></div>
-        </div>
-      </div>
-    </div>
-  </template>
-</test-fixture>
-
-<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: [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.js b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
deleted file mode 100644
index 88c8835..0000000
--- a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
+++ /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.
- */
-
-/** @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.js b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js
deleted file mode 100644
index 4028b18..0000000
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js
+++ /dev/null
@@ -1,155 +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.
- */
-
-/** @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
deleted file mode 100644
index c5f3f94..0000000
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
+++ /dev/null
@@ -1,72 +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">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</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>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<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: [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.js b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js
deleted file mode 100644
index 3c48fbe..0000000
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js
+++ /dev/null
@@ -1,210 +0,0 @@
-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
deleted file mode 100644
index 3f58499..0000000
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
+++ /dev/null
@@ -1,369 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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 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: [
-        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.js b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js
deleted file mode 100644
index 6c469a5..0000000
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js
+++ /dev/null
@@ -1,113 +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.
- */
-
-/** @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
deleted file mode 100644
index 3c5aedd..0000000
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
+++ /dev/null
@@ -1,130 +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">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</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>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<test-fixture id="within-overlay">
-  <template>
-    <gr-overlay>
-      <test-element></test-element>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<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: [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.js b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
deleted file mode 100644
index 607499b..0000000
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
+++ /dev/null
@@ -1,41 +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.
- */
-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
deleted file mode 100644
index fa72c40..0000000
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
+++ /dev/null
@@ -1,100 +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">
-<meta charset="utf-8">
-<title>gr-display-name-behavior</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>
-    <test-element-anon></test-element-anon>
-  </template>
-</test-fixture>
-
-<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: [
-        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.js b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
deleted file mode 100644
index 813c64a..0000000
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
+++ /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.
- */
-
-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
deleted file mode 100644
index 80013bf..0000000
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
+++ /dev/null
@@ -1,93 +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">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</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>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<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: [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.js b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js
deleted file mode 100644
index fafec9d..0000000
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js
+++ /dev/null
@@ -1,301 +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.
- */
-
-// 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
deleted file mode 100644
index f03e3ac..0000000
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ /dev/null
@@ -1,324 +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.
--->
-<!-- Polymer included for the html import polyfill. -->
-<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="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<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.js b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js
deleted file mode 100644
index 7745b42..0000000
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js
+++ /dev/null
@@ -1,120 +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.
- */
-
-/** @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
deleted file mode 100644
index 1b7a42a..0000000
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
+++ /dev/null
@@ -1,100 +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.
--->
-<!-- Polymer included for the html import polyfill. -->
-<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="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<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.js b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js
deleted file mode 100644
index 3ba2e60..0000000
--- a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js
+++ /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.
- */
-
-/** @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.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
deleted file mode 100644
index 2a592f0..0000000
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ /dev/null
@@ -1,173 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../scripts/bundled-polymer.js';
-
-import '../../elements/shared/gr-tooltip/gr-tooltip.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {getRootElement} from '../../scripts/rootElement.js';
-
-const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
-
-/** @polymerBehavior Gerrit.TooltipBehavior */
-export const TooltipBehavior = {
-
-  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,
-    },
-  },
-
-  /** @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);
-  },
-
-  _setupTooltipListeners() {
-    if (!this._mouseenterHandler) {
-      this._mouseenterHandler = this._handleShowTooltip.bind(this);
-    }
-
-    if (!this.hasTooltip) {
-      // if attribute set to false, remove the listener
-      this.removeEventListener('mouseenter', this._mouseenterHandler);
-      this._hasSetupTooltipListeners = false;
-      return;
-    }
-
-    if (this._hasSetupTooltipListeners) {
-      return;
-    }
-    this._hasSetupTooltipListeners = true;
-
-    this.addEventListener('mouseenter', this._mouseenterHandler);
-  },
-
-  _handleShowTooltip(e) {
-    if (this._isTouchDevice) { return; }
-
-    if (!this.hasAttribute('title') ||
-        this.getAttribute('title') === '' ||
-        this._tooltip) {
-      return;
-    }
-
-    // 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', '');
-
-    const tooltip = document.createElement('gr-tooltip');
-    tooltip.text = this._titleText;
-    tooltip.maxWidth = this.getAttribute('max-width');
-    tooltip.positionBelow = this.getAttribute('position-below');
-
-    // 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;
-
-    this._tooltip = tooltip;
-    this.listen(window, 'scroll', '_handleWindowScroll');
-    this.listen(this, 'mouseleave', '_handleHideTooltip');
-    this.listen(this, 'click', '_handleHideTooltip');
-  },
-
-  _handleHideTooltip(e) {
-    if (this._isTouchDevice) { return; }
-    if (!this.hasAttribute('title') ||
-        this._titleText == null) {
-      return;
-    }
-
-    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;
-  },
-
-  _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
deleted file mode 100644
index 79c515e..0000000
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ /dev/null
@@ -1,154 +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.
--->
-
-<title>tooltip-behavior</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>
-    <tooltip-behavior-element></tooltip-behavior-element>
-  </template>
-</test-fixture>
-
-<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; },
-      },
-    };
-  }
-
-  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.js b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js
deleted file mode 100644
index 5c9e911..0000000
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js
+++ /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.
- */
-
-/** @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
deleted file mode 100644
index d0a2cde..0000000
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
+++ /dev/null
@@ -1,92 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<title>gr-url-encoding-behavior</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>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<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: [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.js b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
deleted file mode 100644
index 5d9c74f..0000000
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
+++ /dev/null
@@ -1,661 +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.
-*/
-/*
-  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;
-      }
-      const path = e.composedPath();
-      for (let i = 0; path && i < path.length; i++) {
-        if (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
deleted file mode 100644
index fcb7b4f..0000000
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ /dev/null
@@ -1,442 +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">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</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>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<test-fixture id="within-overlay">
-  <template>
-    <gr-overlay>
-      <test-element></test-element>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<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;
-
-  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']);
-    });
-
-    suite('binding descriptions', () => {
-      function mapToObject(m) {
-        const o = {};
-        m.forEach((v, k) => o[k] = v);
-        return o;
-      }
-
-      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', '↑']);
-      });
-
-      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), [['[']]);
-      });
-
-      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'},
-              ],
-            });
-
-        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'},
-              ],
-            });
-
-        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('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: '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.js b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js
deleted file mode 100644
index 919a763..0000000
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js
+++ /dev/null
@@ -1,201 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../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
deleted file mode 100644
index 980bc8f..0000000
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ /dev/null
@@ -1,237 +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">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</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';
-/** @type {string} */
-window.CANONICAL_PATH = '/r';
-</script>
-
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<test-fixture id="within-overlay">
-  <template>
-    <gr-overlay>
-      <test-element></test-element>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<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: [
-        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.js b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js
deleted file mode 100644
index ec7a9f4..0000000
--- a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js
+++ /dev/null
@@ -1,78 +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.
- */
-
-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
deleted file mode 100644
index 6fe4460..0000000
--- a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
+++ /dev/null
@@ -1,122 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<title>safe-types-behavior</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>
-    <safe-types-element></safe-types-element>
-  </template>
-</test-fixture>
-
-<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: [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/constants.js b/polygerrit-ui/app/constants/constants.js
deleted file mode 100644
index cab50f6..0000000
--- a/polygerrit-ui/app/constants/constants.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @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/constants.ts b/polygerrit-ui/app/constants/constants.ts
new file mode 100644
index 0000000..b64a7d1
--- /dev/null
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -0,0 +1,402 @@
+/**
+ * @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 Tab names for primary tabs on change view page.
+ */
+export enum PrimaryTab {
+  FILES = 'files',
+  /**
+   * When renaming this, the links in UrlFormatter must be updated.
+   */
+  COMMENT_THREADS = 'comments',
+  FINDINGS = 'findings',
+}
+
+/**
+ * @desc Tab names for secondary tabs on change view page.
+ */
+export enum SecondaryTab {
+  CHANGE_LOG = '_changeLog',
+}
+
+/**
+ * @desc Tag names of change log messages.
+ */
+export enum MessageTag {
+  TAG_DELETE_REVIEWER = 'autogenerated:gerrit:deleteReviewer',
+  TAG_NEW_PATCHSET = 'autogenerated:gerrit:newPatchSet',
+  TAG_NEW_WIP_PATCHSET = 'autogenerated:gerrit:newWipPatchSet',
+  TAG_REVIEWER_UPDATE = 'autogenerated:gerrit:reviewerUpdate',
+  TAG_SET_PRIVATE = 'autogenerated:gerrit:setPrivate',
+  TAG_UNSET_PRIVATE = 'autogenerated:gerrit:unsetPrivate',
+  TAG_SET_READY = 'autogenerated:gerrit:setReadyForReview',
+  TAG_SET_WIP = 'autogenerated:gerrit:setWorkInProgress',
+  TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
+  TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
+}
+
+/**
+ * @desc Modes for gr-diff-cursor
+ * The scroll behavior for the cursor. Values are 'never' and
+ * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+ * the viewport.
+ */
+export enum ScrollMode {
+  KEEP_VISIBLE = 'keep-visible',
+  NEVER = 'never',
+}
+
+/**
+ * @desc Specifies status for a change
+ */
+export enum ChangeStatus {
+  ABANDONED = 'ABANDONED',
+  MERGED = 'MERGED',
+  NEW = 'NEW',
+}
+
+/**
+ * @desc Special file paths
+ */
+export enum SpecialFilePath {
+  PATCHSET_LEVEL_COMMENTS = '/PATCHSET_LEVEL',
+  COMMIT_MESSAGE = '/COMMIT_MSG',
+  MERGE_LIST = '/MERGE_LIST',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum RequirementStatus {
+  OK = 'OK',
+  NOT_READY = 'NOT_READY',
+  RULE_ERROR = 'RULE_ERROR',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum ReviewerState {
+  REVIEWER = 'REVIEWER',
+  CC = 'CC',
+  REMOVED = 'REMOVED',
+}
+
+/**
+ * @desc The patchset kind
+ */
+export enum RevisionKind {
+  REWORK = 'REWORK',
+  TRIVIAL_REBASE = 'TRIVIAL_REBASE',
+  MERGE_FIRST_PARENT_UPDATE = 'MERGE_FIRST_PARENT_UPDATE',
+  NO_CODE_CHANGE = 'NO_CODE_CHANGE',
+  NO_CHANGE = 'NO_CHANGE',
+}
+
+/**
+ * @desc The status of fixing the problem
+ */
+export enum ProblemInfoStatus {
+  FIXED = 'FIXED',
+  FIX_FAILED = 'FIX_FAILED',
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum FileInfoStatus {
+  ADDED = 'A',
+  DELETED = 'D',
+  RENAMED = 'R',
+  COPIED = 'C',
+  REWRITTEN = 'W',
+  // Modifed = 'M', // but API not set it if the file was modified
+  UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum GpgKeyInfoStatus {
+  BAD = 'BAD',
+  OK = 'OK',
+  TRUSTED = 'TRUSTED',
+}
+
+/**
+ * @desc Used for server config of accounts
+ */
+export enum DefaultDisplayNameConfig {
+  USERNAME = 'USERNAME',
+  FIRST_NAME = 'FIRST_NAME',
+  FULL_NAME = 'FULL_NAME',
+}
+
+/**
+ * @desc The state of the projects
+ */
+export enum ProjectState {
+  ACTIVE = 'ACTIVE',
+  READ_ONLY = 'READ_ONLY',
+  HIDDEN = 'HIDDEN',
+}
+
+export enum Side {
+  LEFT = 'left',
+  RIGHT = 'right',
+}
+
+/**
+ * The type in ConfigParameterInfo entity.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
+ */
+export enum ConfigParameterInfoType {
+  // Should be kept in sync with
+  // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
+  STRING = 'STRING',
+  INT = 'INT',
+  LONG = 'LONG',
+  BOOLEAN = 'BOOLEAN',
+  LIST = 'LIST',
+  ARRAY = 'ARRAY',
+}
+
+/**
+ * All supported submit types.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
+ */
+export enum SubmitType {
+  MERGE_IF_NECESSARY = 'MERGE_IF_NECESSARY',
+  FAST_FORWARD_ONLY = 'FAST_FORWARD_ONLY',
+  REBASE_IF_NECESSARY = 'REBASE_IF_NECESSARY',
+  REBASE_ALWAYS = 'REBASE_ALWAYS',
+  MERGE_ALWAYS = 'MERGE_ALWAYS ',
+  CHERRY_PICK = 'CHERRY_PICK',
+  INHERIT = 'INHERIT',
+}
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export enum MergeStrategy {
+  RECURSIVE = 'recursive',
+  RESOLVE = 'resolve',
+  SIMPLE_TWO_WAY_IN_CORE = 'simple-two-way-in-core',
+  OURS = 'ours',
+  THEIRS = 'theirs',
+}
+
+/*
+ * Enum for possible configured value in InheritedBooleanInfo.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
+ */
+export enum InheritedBooleanInfoConfiguredValue {
+  TRUE = 'TRUE',
+  FALSE = 'FALSE',
+  INHERITED = 'INHERITED',
+}
+
+export enum AccountTag {
+  SERVICE_USER = 'SERVICE_USER',
+}
+
+/**
+ * Enum for possible PermissionRuleInfo actions
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#permission-info
+ */
+export enum PermissionAction {
+  ALLOW = 'ALLOW',
+  DENY = 'DENY',
+  BLOCK = 'BLOCK',
+  // Special values for global capabilities
+  INTERACTIVE = 'INTERACTIVE',
+  BATCH = 'BATCH',
+}
+
+/**
+ * This capability allows users to use the thread pool reserved for 'Non-Interactive Users'.
+ * https://gerrit-review.googlesource.com/Documentation/access-control.html#capability_priority
+ */
+export enum UserPriority {
+  BATCH = 'BATCH',
+  INTERACTIVE = 'INTERACTIVE',
+}
+
+/**
+ * Enum for all http methods used in Gerrit.
+ */
+export enum HttpMethod {
+  HEAD = 'HEAD',
+  POST = 'POST',
+  GET = 'GET',
+  DELETE = 'DELETE',
+  PUT = 'PUT',
+}
+
+/**
+ * The side on which the comment was added
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export enum CommentSide {
+  REVISION = 'REVISION',
+  PARENT = 'PARENT',
+}
+
+/**
+ * Allowed app themes
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum AppTheme {
+  DARK = 'DARK',
+  LIGHT = 'LIGHT',
+}
+
+/**
+ * Date formats in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DateFormat {
+  STD = 'STD',
+  US = 'US',
+  ISO = 'ISO',
+  EURO = 'EURO',
+  UK = 'UK',
+}
+
+/**
+ * Time formats in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum TimeFormat {
+  HHMM_12 = 'HHMM_12',
+  HHMM_24 = 'HHMM_24',
+}
+
+/**
+ * Diff type in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DiffViewMode {
+  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+  UNIFIED = 'UNIFIED_DIFF',
+}
+
+/**
+ * The type of email strategy to use.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum EmailStrategy {
+  ENABLED = 'ENABLED',
+  CC_ON_OWN_COMMENTS = 'CC_ON_OWN_COMMENTS',
+  ATTENTION_SET_ONLY = 'ATTENTION_SET_ONLY',
+  DISABLED = 'DISABLED',
+}
+
+/**
+ * The type of email format to use.
+ * Doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo.
+ */
+
+export enum EmailFormat {
+  PLAINTEXT = 'PLAINTEXT',
+  HTML_PLAINTEXT = 'HTML_PLAINTEXT',
+}
+
+/**
+ * The base which should be pre-selected in the 'Diff Against' drop-down list when the change screen is opened for a merge commit
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DefaultBase {
+  AUTO_MERGE = 'AUTO_MERGE',
+  FIRST_PARENT = 'FIRST_PARENT',
+}
+
+/**
+ * Whether whitespace changes should be ignored and if yes, which whitespace changes should be ignored
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
+ */
+export enum IgnoreWhitespaceType {
+  IGNORE_NONE = 'IGNORE_NONE',
+  IGNORE_TRAILING = 'IGNORE_TRAILING',
+  IGNORE_LEADING_AND_TRAILING = 'IGNORE_LEADING_AND_TRAILING',
+  IGNORE_ALL = 'IGNORE_ALL',
+}
+
+/**
+ * how draft comments are handled
+ */
+export enum DraftsAction {
+  PUBLISH = 'PUBLISH',
+  PUBLISH_ALL_REVISIONS = 'PUBLISH_ALL_REVISIONS',
+  KEEP = 'KEEP',
+}
+
+export enum NotifyType {
+  NONE = 'NONE',
+  OWNER = 'OWNER',
+  OWNER_REVIEWERS = 'OWNER_REVIEWERS',
+  ALL = 'ALL',
+}
+
+/**
+ * The authentication type that is configured on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum AuthType {
+  OPENID = 'OPENID',
+  OPENID_SSO = 'OPENID_SSO',
+  OAUTH = 'OAUTH',
+  HTTP = 'HTTP',
+  HTTP_LDAP = 'HTTP_LDAP',
+  CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
+  LDAP = 'LDAP',
+  LDAP_BIND = 'LDAP_BIND',
+  CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
+  DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
+}
+
+/**
+ * Controls visibility of other users' dashboard pages and completion suggestions to web users
+ * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#accounts.visibility
+ */
+export enum AccountsVisibility {
+  ALL = 'ALL',
+  SAME_GROUP = 'SAME_GROUP',
+  VISIBLE_GROUP = 'VISIBLE_GROUP',
+  NONE = 'NONE',
+}
+
+/**
+ * Account fields that are editable
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum EditableAccountField {
+  FULL_NAME = 'FULL_NAME',
+  USER_NAME = 'USER_NAME',
+  REGISTER_NEW_EMAIL = 'REGISTER_NEW_EMAIL',
+}
+
+/**
+ * This setting determines when Gerrit computes if a change is mergeable or not.
+ * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#change.mergeabilityComputationBehavior
+ */
+export enum MergeabilityComputationBehavior {
+  API_REF_UPDATED_AND_CHANGE_REINDEX = 'API_REF_UPDATED_AND_CHANGE_REINDEX',
+  REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
+  NEVER = 'NEVER',
+}
diff --git a/polygerrit-ui/app/constants/messages.js b/polygerrit-ui/app/constants/messages.js
deleted file mode 100644
index 8562cd9..0000000
--- a/polygerrit-ui/app/constants/messages.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @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/constants/messages.ts b/polygerrit-ui/app/constants/messages.ts
new file mode 100644
index 0000000..5b4a534
--- /dev/null
+++ b/polygerrit-ui/app/constants/messages.ts
@@ -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.
+ */
+
+/** @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.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
deleted file mode 100644
index 2865f0d..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ /dev/null
@@ -1,312 +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.
- */
-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 a section that was previously added was removed.
- *
- * @event added-section-removed
- */
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrAccessSection extends mixinBehaviors( [
-  AccessBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-access-section'; }
-
-  static get properties() {
-    return {
-      repo: String,
-      capabilities: Object,
-      /** @type {?} */
-      section: {
-        type: Object,
-        notify: true,
-        observer: '_updateSection',
-      },
-      groups: Object,
-      labels: Object,
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_handleEditingChanged',
-      },
-      canUpload: Boolean,
-      ownerOf: Array,
-      _originalId: String,
-      _editingRef: {
-        type: Boolean,
-        value: false,
-      },
-      _deleted: {
-        type: Boolean,
-        value: false,
-      },
-      _permissions: Array,
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('access-saved',
-        () => this._handleAccessSaved());
-  }
-
-  _updateSection(section) {
-    this._permissions = this.toSortedArray(section.value.permissions);
-    this._originalId = section.id;
-  }
-
-  _handleAccessSaved() {
-    // Set a new 'original' value to keep track of after the value has been
-    // saved.
-    this._updateSection(this.section);
-  }
-
-  _handleValueChange() {
-    if (!this.section.value.added) {
-      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 => !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() {
-    this._deleted = false;
-    delete this.section.value.deleted;
-  }
-
-  editRefInput() {
-    return dom(this.root).querySelector(PolymerElement ?
-      'iron-input.editRefInput' :
-      'input[is=iron-input].editRefInput');
-  }
-
-  editReference() {
-    this._editingRef = true;
-    this.editRefInput().focus();
-  }
-
-  _isEditEnabled(canUpload, ownerOf, sectionId) {
-    return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
-  }
-
-  _computeSectionClass(editing, canUpload, ownerOf, editingRef, deleted) {
-    const classList = [];
-    if (editing
-       && this._isEditEnabled(canUpload, ownerOf, this.section.id)) {
-      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.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
new file mode 100644
index 0000000..c52a04c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -0,0 +1,392 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-permission/gr-permission';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {htmlTemplate} from './gr-access-section_html';
+import {
+  AccessPermissions,
+  PermissionArray,
+  PermissionArrayItem,
+  toSortedPermissionsArray,
+} from '../../../utils/access-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+  EditablePermissionInfo,
+  PermissionAccessSection,
+  EditableProjectAccessGroups,
+} from '../gr-repo-access/gr-repo-access-interfaces';
+import {
+  CapabilityInfoMap,
+  GitRef,
+  LabelNameToLabelTypeInfoMap,
+  RepoName,
+} from '../../../types/common';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+
+/**
+ * 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
+ */
+
+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';
+
+export interface GrAccessSection {
+  $: {
+    permissionSelect: HTMLSelectElement;
+  };
+}
+
+@customElement('gr-access-section')
+export class GrAccessSection extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  repo?: RepoName;
+
+  @property({type: Object})
+  capabilities?: CapabilityInfoMap;
+
+  @property({type: Object, notify: true, observer: '_updateSection'})
+  section?: PermissionAccessSection;
+
+  @property({type: Object})
+  groups?: EditableProjectAccessGroups;
+
+  @property({type: Object})
+  labels?: LabelNameToLabelTypeInfoMap;
+
+  @property({type: Boolean, observer: '_handleEditingChanged'})
+  editing = false;
+
+  @property({type: Boolean})
+  canUpload?: boolean;
+
+  @property({type: Array})
+  ownerOf?: GitRef[];
+
+  @property({type: String})
+  _originalId?: GitRef;
+
+  @property({type: Boolean})
+  _editingRef = false;
+
+  @property({type: Boolean})
+  _deleted = false;
+
+  @property({type: Array})
+  _permissions?: PermissionArray<EditablePermissionInfo>;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-saved', () => this._handleAccessSaved());
+  }
+
+  _updateSection(section: PermissionAccessSection) {
+    this._permissions = toSortedPermissionsArray(section.value.permissions);
+    this._originalId = section.id as GitRef;
+  }
+
+  _handleAccessSaved() {
+    if (!this.section) {
+      return;
+    }
+    // Set a new 'original' value to keep track of after the value has been
+    // saved.
+    this._updateSection(this.section);
+  }
+
+  _handleValueChange() {
+    if (!this.section) {
+      return;
+    }
+    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: boolean, editingOld: boolean) {
+    // Ignore when editing gets set initially.
+    if (!editingOld) {
+      return;
+    }
+    if (!this.section || !this._permissions) {
+      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: string,
+    capabilities?: CapabilityInfoMap,
+    labels?: LabelNameToLabelTypeInfoMap
+  ) {
+    let allPermissions;
+    const section = this.section;
+    if (!section || !section.value) {
+      return [];
+    }
+    if (name === GLOBAL_NAME) {
+      allPermissions = toSortedPermissionsArray(capabilities);
+    } else {
+      const labelOptions = this._computeLabelOptions(labels);
+      allPermissions = labelOptions.concat(
+        toSortedPermissionsArray(AccessPermissions)
+      );
+    }
+    return allPermissions.filter(
+      permission => !section.value.permissions[permission.id]
+    );
+  }
+
+  _computeHideEditClass(section: PermissionAccessSection) {
+    return section.id === 'GLOBAL_CAPABILITIES' ? 'hide' : '';
+  }
+
+  _handleAddedPermissionRemoved(e: PolymerDomRepeatEvent) {
+    if (!this._permissions) {
+      return;
+    }
+    const index = e.model.index;
+    this._permissions = this._permissions
+      .slice(0, index)
+      .concat(this._permissions.slice(index + 1, this._permissions.length));
+  }
+
+  _computeLabelOptions(labels?: LabelNameToLabelTypeInfoMap) {
+    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: string,
+    permission: PermissionArrayItem<EditablePermissionInfo>,
+    capabilities: CapabilityInfoMap
+  ) {
+    if (name === GLOBAL_NAME) {
+      return capabilities[permission.id].name;
+    } else if (AccessPermissions[permission.id]) {
+      return AccessPermissions[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}`;
+    }
+    return undefined;
+  }
+
+  _computeSectionName(name: string) {
+    // 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) {
+      return;
+    }
+    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() {
+    if (!this.section) {
+      return;
+    }
+    this._deleted = false;
+    delete this.section.value.deleted;
+  }
+
+  editRefInput() {
+    return this.root!.querySelector(
+      PolymerElement
+        ? 'iron-input.editRefInput'
+        : 'input[is=iron-input].editRefInput'
+    ) as HTMLInputElement;
+  }
+
+  editReference() {
+    this._editingRef = true;
+    this.editRefInput().focus();
+  }
+
+  _isEditEnabled(
+    canUpload: boolean | undefined,
+    ownerOf: GitRef[] | undefined,
+    sectionId: GitRef
+  ) {
+    return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
+  }
+
+  _computeSectionClass(
+    editing: boolean,
+    canUpload: boolean | undefined,
+    ownerOf: GitRef[] | undefined,
+    editingRef: boolean,
+    deleted: boolean
+  ) {
+    const classList = [];
+    if (
+      editing &&
+      this.section &&
+      this._isEditEnabled(canUpload, ownerOf, this.section.id as GitRef)
+    ) {
+      classList.push('editing');
+    }
+    if (editingRef) {
+      classList.push('editingRef');
+    }
+    if (deleted) {
+      classList.push('deleted');
+    }
+    return classList.join(' ');
+  }
+
+  _computeEditBtnClass(name: string) {
+    return name === GLOBAL_NAME ? 'global' : '';
+  }
+
+  _handleAddPermission() {
+    const value = this.$.permissionSelect.value;
+    const permission: PermissionArrayItem<EditablePermissionInfo> = {
+      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);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-access-section': 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
deleted file mode 100644
index 5be78df..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
+++ /dev/null
@@ -1,158 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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]]"
-            repo="[[repo]]"
-            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_html.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
new file mode 100644
index 0000000..4f5a06e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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 class="heading-3">[[_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, capabilities)]]"
+            permission="{{permission}}"
+            labels="[[labels]]"
+            section="[[section.id]]"
+            editing="[[editing]]"
+            groups="[[groups]]"
+            repo="[[repo]]"
+            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
deleted file mode 100644
index 95345fe..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
+++ /dev/null
@@ -1,556 +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">
-<meta charset="utf-8">
-<title>gr-access-section</title>
-
-<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>
-    <gr-access-section></gr-access-section>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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();
-    });
-
-    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('_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/*',
-          value: {
-            permissions: {
-              read: {
-                rules: {},
-              },
-            },
-          },
-        };
-        element.capabilities = {};
-        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.
-        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();
-        });
-      });
-
-      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);
-      });
-
-      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.$.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.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);
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
new file mode 100644
index 0000000..97a7fa3
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
@@ -0,0 +1,523 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-access-section.js';
+import {AccessPermissions, toSortedPermissionsArray} from '../../../utils/access-util.js';
+
+const fixture = fixtureFromElement('gr-access-section');
+
+suite('gr-access-section tests', () => {
+  let element;
+
+  setup(() => {
+    element = fixture.instantiate();
+  });
+
+  suite('unit tests', () => {
+    setup(() => {
+      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);
+      flush();
+    });
+
+    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('_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', () => {
+      const capabilities = {
+        push: {
+          rules: {},
+        },
+        read: {
+          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, capabilities,
+          element.labels), expectedPermissions);
+
+      // For everything else, include possible label values before filtering.
+      name = 'refs/for/*';
+      assert.deepEqual(
+          element._computePermissions(name, capabilities, element.labels),
+          labelOptions
+              .concat(toSortedPermissionsArray(AccessPermissions))
+              .filter(permission => permission.id !== 'read'));
+    });
+
+    test('_computePermissionName', () => {
+      let name = 'GLOBAL_CAPABILITIES';
+      let permission = {
+        id: 'administrateServer',
+        value: {},
+      };
+      assert.equal(element._computePermissionName(name, permission,
+          element.capabilities),
+      element.capabilities[permission.id].name);
+
+      name = 'refs/for/*';
+      permission = {
+        id: 'abandon',
+        value: {},
+      };
+
+      assert.equal(element._computePermissionName(
+          name, permission, element.capabilities),
+      AccessPermissions[permission.id].name);
+
+      name = 'refs/for/*';
+      permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+        },
+      };
+
+      assert.equal(element._computePermissionName(name, permission,
+          element.capabilities),
+      'Label Code-Review');
+
+      permission = {
+        id: 'labelAs-Code-Review',
+        value: {
+          label: 'Code-Review',
+        },
+      };
+
+      assert.equal(element._computePermissionName(name, permission,
+          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 default 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);
+        flush();
+      });
+
+      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.capabilities = {};
+        element._updateSection(element.section);
+        flush();
+      });
+
+      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 = [];
+        flush();
+        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);
+        flush();
+
+        // 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);
+        flush();
+
+        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.
+        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();
+        });
+      });
+
+      test('_handleValueChange', () => {
+        // For an existing section.
+        const modifiedHandler = sinon.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);
+      });
+
+      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);
+        flush();
+        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);
+        flush();
+        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.shadowRoot
+            .querySelector('gr-permission').dispatchEvent(
+                new CustomEvent('added-permission-removed', {
+                  composed: true, bubbles: true,
+                }));
+        flush();
+        assert.equal(element._permissions.length, 0);
+      });
+
+      test('remove an added section', () => {
+        const removeStub = sinon.stub();
+        element.addEventListener('added-section-removed', removeStub);
+        element.editing = true;
+        element.section.value.added = true;
+        MockInteractions.tap(element.$.deleteBtn);
+        assert.isTrue(removeStub.called);
+      });
+    });
+  });
+});
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
deleted file mode 100644
index 36e2cc4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ /dev/null
@@ -1,189 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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',
-      },
-
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _path: {
-        type: String,
-        readOnly: true,
-        value: '/admin/groups',
-      },
-      _hasNewGroupName: Boolean,
-      _createNewCapability: {
-        type: Boolean,
-        value: false,
-      },
-      _groups: Array,
-
-      /**
-       * Because  we request one more than the groupsPerPage, _shownGroups
-       * may be one less than _groups.
-       * */
-      _shownGroups: {
-        type: Array,
-        computed: 'computeShownItems(_groups)',
-      },
-
-      _groupsPerPage: {
-        type: Number,
-        value: 25,
-      },
-
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: String,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getCreateGroupCapability();
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Groups'},
-      composed: true, bubbles: true,
-    }));
-    this._maybeOpenCreateOverlay(this.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);
-  }
-
-  /**
-   * 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 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.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
new file mode 100644
index 0000000..9d40e28
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -0,0 +1,192 @@
+/**
+ * @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 '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-group-dialog/gr-create-group-dialog';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-admin-group-list_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe, computed} from '@polymer/decorators';
+import {AppElementAdminParams} from '../../gr-app-types';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-admin-group-list': GrAdminGroupList;
+  }
+}
+
+export interface GrAdminGroupList {
+  $: {
+    createOverlay: GrOverlay;
+    createNewModal: GrCreateGroupDialog;
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-admin-group-list')
+export class GrAdminGroupList extends ListViewMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  params?: AppElementAdminParams;
+
+  /**
+   * Offset of currently visible query results.
+   */
+  @property({type: Number})
+  _offset?: number;
+
+  @property({type: String})
+  readonly _path = '/admin/groups';
+
+  @property({type: Boolean})
+  _hasNewGroupName?: boolean;
+
+  @property({type: Boolean})
+  _createNewCapability = false;
+
+  @property({type: Array})
+  _groups: GroupInfo[] = [];
+
+  /**
+   * Because  we request one more than the groupsPerPage, _shownGroups
+   * may be one less than _groups.
+   * */
+  @computed('_groups')
+  get _shownGroups() {
+    return this.computeShownItems(this._groups);
+  }
+
+  @property({type: Number})
+  _groupsPerPage = 25;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _filter = '';
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getCreateGroupCapability();
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: 'Groups'},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    this._maybeOpenCreateOverlay(this.params);
+  }
+
+  @observe('params')
+  _paramsChanged(params: AppElementAdminParams) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+
+    return this._getGroups(this._filter, this._groupsPerPage, this._offset);
+  }
+
+  /**
+   * Opens the create overlay if the route has a hash 'create'
+   */
+  _maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+    if (params?.openCreateModal) {
+      this.$.createOverlay.open();
+    }
+  }
+
+  /**
+   * Generates groups link (/admin/groups/<uuid>)
+   */
+  _computeGroupUrl(id: string) {
+    return GerritNav.getUrlForGroup(decodeURIComponent(id) as GroupId);
+  }
+
+  _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: string, groupsPerPage: number, offset?: number) {
+    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 as GroupName;
+          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: GroupInfo) {
+    return item.options?.visible_to_all === true ? 'Y' : 'N';
+  }
+}
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
deleted file mode 100644
index 4548a45..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
new file mode 100644
index 0000000..93de8b4
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
@@ -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';
+
+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}}"
+          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
deleted file mode 100644
index c8c7f0c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
+++ /dev/null
@@ -1,219 +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">
-<meta charset="utf-8">
-<title>gr-admin-group-list</title>
-
-<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>
-    <gr-admin-group-list></gr-admin-group-list>
-  </template>
-</test-fixture>
-
-<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;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_computeGroupUrl', () => {
-    let urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    let group = {
-      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
-    };
-    assert.equal(element._computeGroupUrl(group),
-        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    urlStub.restore();
-
-    urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
-        () => '/admin/groups/user/test');
-
-    group = {
-      id: 'user%2Ftest',
-    };
-    assert.equal(element._computeGroupUrl(group),
-        '/admin/groups/user/test');
-
-    urlStub.restore();
-  });
-
-  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',
-          () => 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-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
new file mode 100644
index 0000000..93d41c3
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
@@ -0,0 +1,200 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-admin-group-list.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import 'lodash/lodash.js';
+
+const basicFixture = fixtureFromElement('gr-admin-group-list');
+
+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 value;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeGroupUrl', () => {
+    let urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
+        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    let group = {
+      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
+    };
+    assert.equal(element._computeGroupUrl(group),
+        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    urlStub.restore();
+
+    urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
+        () => '/admin/groups/user/test');
+
+    group = {
+      id: 'user%2Ftest',
+    };
+    assert.equal(element._computeGroupUrl(group),
+        '/admin/groups/user/test');
+
+    urlStub.restore();
+  });
+
+  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 = sinon.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 => {
+      sinon.stub(
+          element.$.restAPI,
+          'getGroups')
+          .callsFake(() => 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);
+
+      flush();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('create new', () => {
+    test('_handleCreateClicked called when create-click fired', () => {
+      sinon.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 = sinon.stub(element.$.createOverlay, 'open');
+      element._handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateGroup called when confirm fired', () => {
+      sinon.stub(element, '_handleCreateGroup');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCreateGroup.called);
+    });
+
+    test('_handleCloseCreate called when cancel fired', () => {
+      sinon.stub(element, '_handleCloseCreate');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCloseCreate.called);
+    });
+  });
+});
+
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
deleted file mode 100644
index b318ee6..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ /dev/null
@@ -1,320 +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.
- */
-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}$/;
-
-/**
- * @extends Polymer.Element
- */
-class GrAdminView extends mixinBehaviors( [
-  AdminNavBehavior,
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-admin-view'; }
-
-  static get properties() {
-    return {
-    /** @type {?} */
-      params: Object,
-      path: String,
-      adminView: String,
-
-      _breadcrumbParentName: String,
-      _repoName: String,
-      _groupId: {
-        type: Number,
-        observer: '_computeGroupName',
-      },
-      _groupIsInternal: Boolean,
-      _groupName: String,
-      _groupOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _subsectionLinks: Array,
-      _filteredLinks: Array,
-      _showDownload: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-      _showGroup: Boolean,
-      _showGroupAuditLog: Boolean,
-      _showGroupList: Boolean,
-      _showGroupMembers: Boolean,
-      _showRepoAccess: Boolean,
-      _showRepoCommands: Boolean,
-      _showRepoDashboards: Boolean,
-      _showRepoDetailList: Boolean,
-      _showRepoMain: Boolean,
-      _showRepoList: Boolean,
-      _showPluginList: Boolean,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_paramsChanged(params)',
-    ];
-  }
-
-  /** @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();
-
-      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();
-  }
-}
-
-customElements.define(GrAdminView.is, GrAdminView);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
new file mode 100644
index 0000000..7fb713f
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -0,0 +1,463 @@
+/**
+ * @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 '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-admin-group-list/gr-admin-group-list';
+import '../gr-group/gr-group';
+import '../gr-group-audit-log/gr-group-audit-log';
+import '../gr-group-members/gr-group-members';
+import '../gr-plugin-list/gr-plugin-list';
+import '../gr-repo/gr-repo';
+import '../gr-repo-access/gr-repo-access';
+import '../gr-repo-commands/gr-repo-commands';
+import '../gr-repo-dashboards/gr-repo-dashboards';
+import '../gr-repo-detail-list/gr-repo-detail-list';
+import '../gr-repo-list/gr-repo-list';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-admin-view_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {
+  GerritNav,
+  GerritView,
+  GroupDetailView,
+  RepoDetailView,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  AdminNavLinksOption,
+  getAdminLinks,
+  NavLink,
+  SubsectionInterface,
+} from '../../../utils/admin-nav-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AppElementAdminParams,
+  AppElementGroupParams,
+  AppElementRepoParams,
+} from '../../gr-app-types';
+import {
+  AccountDetailInfo,
+  GroupId,
+  GroupName,
+  RepoName,
+} from '../../../types/common';
+import {GroupNameChangedDetail} from '../gr-group/gr-group';
+import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+
+export interface GrAdminView {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: GrJsApiInterface;
+  };
+}
+
+interface AdminSubsectionLink {
+  text: string;
+  value: string;
+  view: GerritView;
+  url: string;
+  detailType?: GroupDetailView | RepoDetailView;
+  parent?: GroupId | RepoName;
+}
+
+// The type is matched to the _showAdminView function from the gr-app-element
+type AdminViewParams =
+  | AppElementAdminParams
+  | AppElementGroupParams
+  | AppElementRepoParams;
+
+function getAdminViewParamsDetail(
+  params: AdminViewParams
+): GroupDetailView | RepoDetailView | undefined {
+  if (params.view !== GerritView.ADMIN) {
+    return params.detail;
+  }
+  return undefined;
+}
+
+@customElement('gr-admin-view')
+export class GrAdminView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  private _account?: AccountDetailInfo;
+
+  @property({type: Object})
+  params?: AdminViewParams;
+
+  @property({type: String})
+  path?: string;
+
+  @property({type: String})
+  adminView?: string;
+
+  @property({type: String})
+  _breadcrumbParentName?: string;
+
+  @property({type: String})
+  _repoName?: RepoName;
+
+  @property({type: String, observer: '_computeGroupName'})
+  _groupId?: GroupId;
+
+  @property({type: Boolean})
+  _groupIsInternal?: boolean;
+
+  @property({type: String})
+  _groupName?: GroupName;
+
+  @property({type: Boolean})
+  _groupOwner = false;
+
+  @property({type: Array})
+  _subsectionLinks?: AdminSubsectionLink[];
+
+  @property({type: Array})
+  _filteredLinks?: NavLink[];
+
+  @property({type: Boolean})
+  _showDownload = false;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  @property({type: Boolean})
+  _showGroup?: boolean;
+
+  @property({type: Boolean})
+  _showGroupAuditLog?: boolean;
+
+  @property({type: Boolean})
+  _showGroupList?: boolean;
+
+  @property({type: Boolean})
+  _showGroupMembers?: boolean;
+
+  @property({type: Boolean})
+  _showRepoAccess?: boolean;
+
+  @property({type: Boolean})
+  _showRepoCommands?: boolean;
+
+  @property({type: Boolean})
+  _showRepoDashboards?: boolean;
+
+  @property({type: Boolean})
+  _showRepoDetailList?: boolean;
+
+  @property({type: Boolean})
+  _showRepoMain?: boolean;
+
+  @property({type: Boolean})
+  _showRepoList?: boolean;
+
+  @property({type: Boolean})
+  _showPluginList?: boolean;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.reload();
+  }
+
+  reload() {
+    const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
+      this.$.restAPI.getAccount(),
+      getPluginLoader().awaitPluginsLoaded(),
+    ];
+    return Promise.all(promises).then(result => {
+      this._account = result[0];
+      let options: AdminNavLinksOption | undefined = undefined;
+      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 getAdminLinks(
+        this._account,
+        () =>
+          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+            if (!capabilities) {
+              throw new Error('getAccountCapabilities returns undefined');
+            }
+            return capabilities;
+          }),
+        () => this.$.jsAPI.getAdminMenuLinks(),
+        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: AdminViewParams) {
+    if (!params || !params.view) return;
+    return `${params.view}${getAdminViewParamsDetail(params) ?? ''}`;
+  }
+
+  _selectedIsCurrentPage(selected: AdminSubsectionLink) {
+    if (!this.params) return false;
+
+    return (
+      selected.parent === (this._repoName ?? this._groupId) &&
+      selected.view === this.params.view &&
+      selected.detailType === getAdminViewParamsDetail(this.params)
+    );
+  }
+
+  _handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
+    if (!this._subsectionLinks) return;
+
+    // The GrDropdownList items are _subsectionLinks, so find(...) always return
+    // an item _subsectionLinks and never returns undefined
+    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);
+  }
+
+  @observe('params')
+  _paramsChanged(params: AdminViewParams) {
+    this.set('_showGroup', params.view === GerritView.GROUP && !params.detail);
+    this.set(
+      '_showGroupAuditLog',
+      params.view === GerritView.GROUP && params.detail === GroupDetailView.LOG
+    );
+    this.set(
+      '_showGroupMembers',
+      params.view === GerritView.GROUP &&
+        params.detail === GroupDetailView.MEMBERS
+    );
+
+    this.set(
+      '_showGroupList',
+      params.view === GerritView.ADMIN &&
+        params.adminView === 'gr-admin-group-list'
+    );
+
+    this.set(
+      '_showRepoAccess',
+      params.view === GerritView.REPO && params.detail === RepoDetailView.ACCESS
+    );
+    this.set(
+      '_showRepoCommands',
+      params.view === GerritView.REPO &&
+        params.detail === RepoDetailView.COMMANDS
+    );
+    this.set(
+      '_showRepoDetailList',
+      params.view === GerritView.REPO &&
+        (params.detail === RepoDetailView.BRANCHES ||
+          params.detail === RepoDetailView.TAGS)
+    );
+    this.set(
+      '_showRepoDashboards',
+      params.view === GerritView.REPO &&
+        params.detail === RepoDetailView.DASHBOARDS
+    );
+    this.set(
+      '_showRepoMain',
+      params.view === GerritView.REPO && !params.detail
+    );
+
+    this.set(
+      '_showRepoList',
+      params.view === GerritView.ADMIN && params.adminView === 'gr-repo-list'
+    );
+
+    this.set(
+      '_showPluginList',
+      params.view === GerritView.ADMIN && params.adminView === 'gr-plugin-list'
+    );
+
+    let needsReload = false;
+    const newRepoName =
+      params.view === GerritView.REPO ? params.repo : undefined;
+    if (newRepoName !== this._repoName) {
+      this._repoName = newRepoName;
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    const newGroupId =
+      params.view === GerritView.GROUP ? params.groupId : undefined;
+    if (newGroupId !== this._groupId) {
+      this._groupId = newGroupId;
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    if (
+      this._breadcrumbParentName &&
+      (params.view !== GerritView.GROUP || !params.groupId) &&
+      (params.view !== GerritView.REPO || !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: string, path: string) {
+    return '//' + host + getBaseUrl() + path;
+  }
+
+  _computeRelativeURL(path: string) {
+    const host = window.location.host;
+    return this._computeURLHelper(host, path);
+  }
+
+  _computeLinkURL(link: NavLink | SubsectionInterface) {
+    if (!link || typeof link.url === 'undefined') return '';
+
+    if ((link as NavLink).target || !(link as NavLink).noBaseUrl) {
+      return link.url;
+    }
+    return this._computeRelativeURL(link.url);
+  }
+
+  _computeSelectedClass(
+    itemView?: GerritView,
+    params?: AdminViewParams,
+    detailType?: GroupDetailView | RepoDetailView
+  ) {
+    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 === GerritView.GROUP && itemView === GerritView.GROUP) {
+      if (!params.detail && !detailType) {
+        return 'selected';
+      }
+      if (params.detail === detailType) {
+        return 'selected';
+      }
+      return '';
+    }
+
+    if (params.view === GerritView.REPO && itemView === GerritView.REPO) {
+      if (!params.detail && !detailType) {
+        return 'selected';
+      }
+      if (params.detail === detailType) {
+        return 'selected';
+      }
+      return '';
+    }
+    // TODO(TS): The following condtion seems always false, because params
+    // never has detailType property. Remove it.
+    if (
+      ((params as unknown) as AdminSubsectionLink).detailType &&
+      ((params as unknown) as AdminSubsectionLink).detailType !== detailType
+    ) {
+      return '';
+    }
+    return params.view === GerritView.ADMIN && itemView === params.adminView
+      ? 'selected'
+      : '';
+  }
+
+  _computeGroupName(groupId?: GroupId) {
+    if (!groupId) return;
+
+    const promises: Array<Promise<void>> = [];
+    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();
+
+      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: CustomEvent<GroupNameChangedDetail>) {
+    this._groupName = e.detail.name;
+    this.reload();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-admin-view': 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
deleted file mode 100644
index b62a41b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js
+++ /dev/null
@@ -1,183 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
new file mode 100644
index 0000000..5e85a93
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
@@ -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';
+
+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
deleted file mode 100644
index ec72bfd..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
+++ /dev/null
@@ -1,684 +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">
-<meta charset="utf-8">
-<title>gr-admin-view</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-admin-view></gr-admin-view>
-  </template>
-</test-fixture>
-
-<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';
-
-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,
-      });
-      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',
-        () => 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.shadowRoot.querySelector('#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',
-        () => 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',
-        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(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);
-
-      // 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: () => {},
-        });
-      });
-
-      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();
-      });
-
-      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('internal group', () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flushAsynchronousOperations();
-          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(), 'foo');
-        });
-      });
-
-      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 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(), 'foo');
-        });
-      });
-
-      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-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
new file mode 100644
index 0000000..44fd4d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -0,0 +1,664 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-admin-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-admin-view');
+
+suite('gr-admin-view tests', () => {
+  let element;
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    stub('gr-rest-api-interface', {
+      getProjectConfig() {
+        return Promise.resolve({});
+      },
+    });
+    const pluginsLoaded = Promise.resolve();
+    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
+    pluginsLoaded.then(() => flush(done));
+  });
+
+  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');
+
+    stubBaseUrl('/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',
+    };
+
+    flush();
+    assert.equal(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 => {
+    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => 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 => {
+    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => 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', () => {
+    sinon.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: undefined,
+        url: '/internal/link/url',
+        name: 'internal link text',
+        noBaseUrl: true,
+        view: null,
+        viewableToAll: true,
+        target: null,
+      });
+      assert.deepEqual(element._filteredLinks[2], {
+        capability: undefined,
+        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';
+    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    element.reload().then(() => {
+      flush();
+      assert.equal(dom(element.root)
+          .querySelectorAll('.sectionTitle').length, 3);
+      assert.equal(element.shadowRoot
+          .querySelector('.breadcrumbText').innerText, 'Test Repo');
+      assert.equal(
+          element.shadowRoot.querySelector('#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;
+    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    element.reload().then(() => {
+      flush();
+      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', () => {
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccount')
+        .callsFake(() => Promise.resolve({_id: 1}));
+    sinon.stub(element, 'reload');
+    element.params = {repo: 'Test Repo', view: GerritView.REPO};
+    assert.equal(element.reload.callCount, 1);
+    element.params = {repo: 'Test Repo 2',
+      view: GerritView.REPO};
+    assert.equal(element.reload.callCount, 2);
+  });
+
+  test('Nav is reloaded when group changes', () => {
+    sinon.stub(element, '_computeGroupName');
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccount')
+        .callsFake(() => Promise.resolve({_id: 1}));
+    sinon.stub(element, 'reload');
+    element.params = {groupId: '1', view: GerritView.GROUP};
+    assert.equal(element.reload.callCount, 1);
+  });
+
+  test('Nav is reloaded when group name changes', done => {
+    const newName = 'newName';
+    sinon.stub(element, '_computeGroupName');
+    sinon.stub(element, 'reload').callsFake(() => {
+      assert.equal(element._groupName, newName);
+      assert.isTrue(element.reload.called);
+      done();
+    });
+    element.params = {group: 1, view: GerritNav.View.GROUP};
+    element._groupName = 'oldName';
+    flush();
+    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,
+      },
+    ];
+    flush();
+    assert.isOk(element.shadowRoot
+        .querySelector('.mainHeader'));
+    element._subsectionLinks = undefined;
+    flush();
+    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,
+    };
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccount')
+        .callsFake(() => Promise.resolve({_id: 1}));
+    flush();
+    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',
+      },
+    ];
+    sinon.stub(GerritNav, 'navigateToRelativeUrl');
+    sinon.spy(element, '_selectedIsCurrentPage');
+    sinon.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);
+
+      // 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(() => {
+      sinon.stub(
+          element.$.restAPI,
+          'getAccountCapabilities')
+          .callsFake(() => Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          }));
+      sinon.stub(
+          element.$.restAPI,
+          'getAccount')
+          .callsFake(() => Promise.resolve({_id: 1}));
+
+      return element.reload();
+    });
+
+    suite('repos', () => {
+      setup(() => {
+        stub('gr-repo-access', {
+          _repoChanged: () => {},
+        });
+      });
+
+      test('repo list', () => {
+        element.params = {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-repo-list',
+          openCreateModal: false,
+        };
+        flush();
+        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(() => {
+          flush();
+          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(() => {
+          flush();
+          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(() => {
+          flush();
+          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: () => {},
+        });
+
+        sinon.stub(element.$.restAPI, 'getGroupConfig')
+            .returns(Promise.resolve({
+              name: 'foo',
+              id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
+            }));
+        sinon.stub(element.$.restAPI, 'getIsGroupOwner')
+            .returns(Promise.resolve(true));
+        return element.reload();
+      });
+
+      test('group list', () => {
+        element.params = {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          openCreateModal: false,
+        };
+        flush();
+        const selected = element.shadowRoot
+            .querySelector('gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent.trim(), 'Groups');
+      });
+
+      test('internal group', () => {
+        element.params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
+          flush();
+          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(), 'foo');
+        });
+      });
+
+      test('external group', () => {
+        element.$.restAPI.getGroupConfig.restore();
+        sinon.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(() => {
+          flush();
+          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(), 'foo');
+        });
+      });
+
+      test('group members', () => {
+        element.params = {
+          view: GerritNav.View.GROUP,
+          detail: GerritNav.GroupDetailView.MEMBERS,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
+          flush();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'Members');
+        });
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index b6b21bd..0000000
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const DETAIL_TYPES = {
-  BRANCHES: 'branches',
-  ID: 'id',
-  TAGS: 'tags',
-};
-
-/**
- * @extends Polymer.Element
- */
-class GrConfirmDeleteItemDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-confirm-delete-item-dialog'; }
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  static get properties() {
-    return {
-      item: String,
-      itemType: String,
-    };
-  }
-
-  _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,
-    }));
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
new file mode 100644
index 0000000..79a3e95
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -0,0 +1,98 @@
+/**
+ * @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 '../../shared/gr-dialog/gr-dialog';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-delete-item-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+
+// TODO(TS): add description for this
+export enum DetailType {
+  BRANCHES = 'branches',
+  ID = 'id',
+  TAGS = 'tags',
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-delete-item-dialog': GrConfirmDeleteItemDialog;
+  }
+}
+
+@customElement('gr-confirm-delete-item-dialog')
+export class GrConfirmDeleteItemDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: String})
+  item?: string;
+
+  @property({type: String})
+  itemType?: DetailType;
+
+  _handleConfirmTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCancelTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _computeItemName(detailType: DetailType) {
+    if (detailType === DetailType.BRANCHES) {
+      return 'Branch';
+    } else if (detailType === DetailType.TAGS) {
+      return 'Tag';
+    } else if (detailType === DetailType.ID) {
+      return 'ID';
+    }
+    // TODO(TS): should never happen, this is to pass:
+    // not all code returns value
+    return '';
+  }
+}
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
deleted file mode 100644
index 3810d32..0000000
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
new file mode 100644
index 0000000..ce9ac9c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 003edfb..0000000
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
+++ /dev/null
@@ -1,90 +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">
-<meta charset="utf-8">
-<title>gr-confirm-delete-item-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-delete-item-dialog></gr-confirm-delete-item-dialog>
-  </template>
-</test-fixture>
-
-<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.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-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js
new file mode 100644
index 0000000..485a48b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js
@@ -0,0 +1,70 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-confirm-delete-item-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-delete-item-dialog');
+
+suite('gr-confirm-delete-item-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sinon.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 = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sinon.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');
+  });
+});
+
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
deleted file mode 100644
index 3347655..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ /dev/null
@@ -1,170 +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.
- */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-
-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';
-
-const SUGGESTIONS_LIMIT = 15;
-const REF_PREFIX = 'refs/heads/';
-
-/**
- * @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 {?} */
-      _repoConfig: Object,
-      subject: String,
-      topic: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getRepoBranchesSuggestions.bind(this);
-        },
-      },
-      baseChange: String,
-      baseCommit: String,
-      privateByDefault: String,
-      canCreate: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
-      _privateChangesEnabled: Boolean,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    if (!this.repoName) { return Promise.resolve(); }
-
-    const promises = [];
-
-    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; }
-
-      this._privateConfig = config && config.change &&
-          config.change.disable_private_changes;
-    }));
-
-    return Promise.all(promises);
-  }
-
-  static get observers() {
-    return [
-      '_allowCreate(branch, subject)',
-    ];
-  }
-
-  _computeBranchClass(baseChange) {
-    return baseChange ? 'hide' : '';
-  }
-
-  _allowCreate(branch, subject) {
-    this.canCreate = !!branch && !!subject;
-  }
-
-  handleCreateChange() {
-    const isPrivate = this.$.privateChangeCheckBox.checked;
-    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;
-      } else {
-        return false;
-      }
-    } else {
-      return false;
-    }
-  }
-
-  _computePrivateSectionClass(config) {
-    return config ? 'hide' : '';
-  }
-}
-
-customElements.define(GrCreateChangeDialog.is, GrCreateChangeDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
new file mode 100644
index 0000000..2a6c7a8
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -0,0 +1,225 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-create-change-dialog_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+  RepoName,
+  BranchName,
+  ChangeId,
+  ConfigInfo,
+  InheritedBooleanInfo,
+} from '../../../types/common';
+import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+
+const SUGGESTIONS_LIMIT = 15;
+const REF_PREFIX = 'refs/heads/';
+
+export interface GrCreateChangeDialog {
+  $: {
+    restAPI: RestApiService & Element;
+    privateChangeCheckBox: HTMLInputElement;
+    branchInput: GrAutocomplete;
+    tagNameInput: HTMLInputElement;
+    messageInput: IronAutogrowTextareaElement;
+  };
+}
+@customElement('gr-create-change-dialog')
+export class GrCreateChangeDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  repoName?: RepoName;
+
+  @property({type: String})
+  branch?: BranchName;
+
+  @property({type: Object})
+  _repoConfig?: ConfigInfo;
+
+  @property({type: String})
+  subject?: string;
+
+  @property({type: String})
+  topic?: string;
+
+  @property({type: Object})
+  _query?: (input: string) => Promise<{name: string}[]>;
+
+  @property({type: String})
+  baseChange?: ChangeId;
+
+  @property({type: String})
+  baseCommit?: string;
+
+  @property({type: Object})
+  privateByDefault?: InheritedBooleanInfo;
+
+  @property({type: Boolean, notify: true})
+  canCreate = false;
+
+  @property({type: Boolean})
+  _privateChangesEnabled?: boolean;
+
+  constructor() {
+    super();
+    this._query = (input: string) => this._getRepoBranchesSuggestions(input);
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    if (!this.repoName) {
+      return Promise.resolve();
+    }
+
+    const promises = [];
+
+    promises.push(
+      this.$.restAPI.getProjectConfig(this.repoName).then(config => {
+        if (!config) return;
+        this.privateByDefault = config.private_by_default;
+      })
+    );
+
+    promises.push(
+      this.$.restAPI.getConfig().then(config => {
+        if (!config) {
+          return;
+        }
+
+        this._privateChangesEnabled =
+          config && config.change && !config.change.disable_private_changes;
+      })
+    );
+
+    return Promise.all(promises);
+  }
+
+  _computeBranchClass(baseChange: boolean) {
+    return baseChange ? 'hide' : '';
+  }
+
+  @observe('branch', 'subject')
+  _allowCreate(branch: BranchName, subject: string) {
+    this.canCreate = !!branch && !!subject;
+  }
+
+  handleCreateChange(): Promise<void> {
+    if (!this.repoName || !this.branch || !this.subject) {
+      return Promise.resolve();
+    }
+    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 || undefined
+      )
+      .then(changeCreated => {
+        if (!changeCreated) {
+          return;
+        }
+        GerritNav.navigateToChange(changeCreated);
+      });
+  }
+
+  _getRepoBranchesSuggestions(input: string) {
+    if (!this.repoName) {
+      return Promise.reject(new Error('missing repo name'));
+    }
+    if (input.startsWith(REF_PREFIX)) {
+      input = input.substring(REF_PREFIX.length);
+    }
+    return this.$.restAPI
+      .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
+      .then(response => {
+        if (!response) return [];
+        const branches = [];
+        let branch;
+        for (const key in response) {
+          if (!hasOwnProperty(response, 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: InheritedBooleanInfo) {
+    if (
+      config &&
+      config.configured_value === InheritedBooleanInfoConfiguredValue.TRUE
+    ) {
+      return true;
+    } else if (
+      config &&
+      config.configured_value === InheritedBooleanInfoConfiguredValue.FALSE
+    ) {
+      return false;
+    } else if (
+      config &&
+      config.configured_value === InheritedBooleanInfoConfiguredValue.INHERITED
+    ) {
+      return !!(config && config.inherited_value);
+    } else {
+      return false;
+    }
+  }
+
+  _computePrivateSectionClass(config: boolean) {
+    return config ? 'hide' : '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-change-dialog': 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
deleted file mode 100644
index f18da81..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
new file mode 100644
index 0000000..77e2c3b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 87105a7..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ /dev/null
@@ -1,167 +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">
-<meta charset="utf-8">
-<title>gr-create-change-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-create-change-dialog></gr-create-change-dialog>
-  </template>
-</test-fixture>
-
-<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,
-      },
-    };
-  });
-
-  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', () => 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',
-      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-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
new file mode 100644
index 0000000..e529730
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -0,0 +1,149 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-create-change-dialog.js';
+import {GrCreateChangeDialog} from './gr-create-change-dialog';
+import {BranchName, GitRef, RepoName} from '../../../types/common';
+import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
+import {createChange, createConfig} from '../../../test/test-data-generators';
+
+const basicFixture = fixtureFromElement('gr-create-change-dialog');
+
+suite('gr-create-change-dialog tests', () => {
+  let element: GrCreateChangeDialog;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() {
+        return Promise.resolve(true);
+      },
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch' as GitRef,
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve([]);
+        }
+      },
+    });
+    element = basicFixture.instantiate();
+    element.repoName = 'test-repo' as RepoName;
+    element._repoConfig = {
+      ...createConfig(),
+      private_by_default: {
+        value: false,
+        configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+        inherited_value: false,
+      },
+    };
+  });
+
+  test('new change created with default', async () => {
+    const configInputObj = {
+      branch: 'test-branch',
+      subject: 'first change created with polygerrit ui',
+      topic: 'test-topic',
+      is_private: false,
+      work_in_progress: true,
+    };
+
+    const saveStub = sinon
+      .stub(element.$.restAPI, 'createChange')
+      .callsFake(() => Promise.resolve(createChange()));
+
+    element.branch = 'test-branch' as BranchName;
+    element.topic = 'test-topic';
+    element.subject = 'first change created with polygerrit ui';
+    assert.isFalse(element.$.privateChangeCheckBox.checked);
+
+    element.$.messageInput.bindValue = configInputObj.subject;
+
+    await element.handleCreateChange();
+    // Private change
+    assert.isFalse(saveStub.lastCall.args[4]);
+    // WIP Change
+    assert.isTrue(saveStub.lastCall.args[5]);
+    assert.isTrue(saveStub.called);
+  });
+
+  test('new change created with private', async () => {
+    element.privateByDefault = {
+      configured_value: InheritedBooleanInfoConfiguredValue.TRUE,
+      inherited_value: false,
+      value: true,
+    };
+    sinon.stub(element, '_formatBooleanString').callsFake(() => true);
+    flush();
+
+    const configInputObj = {
+      branch: 'test-branch',
+      subject: 'first change created with polygerrit ui',
+      topic: 'test-topic',
+      is_private: true,
+      work_in_progress: true,
+    };
+
+    const saveStub = sinon
+      .stub(element.$.restAPI, 'createChange')
+      .callsFake(() => Promise.resolve(createChange()));
+
+    element.branch = 'test-branch' as BranchName;
+    element.topic = 'test-topic';
+    element.subject = 'first change created with polygerrit ui';
+    assert.isTrue(element.$.privateChangeCheckBox.checked);
+
+    element.$.messageInput.bindValue = configInputObj.subject;
+
+    await element.handleCreateChange();
+    // Private change
+    assert.isTrue(saveStub.lastCall.args[4]);
+    // WIP Change
+    assert.isTrue(saveStub.lastCall.args[5]);
+    assert.isTrue(saveStub.called);
+  });
+
+  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), '');
+  });
+});
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
deleted file mode 100644
index b21bdde..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
+++ /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.
- */
-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-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';
-
-/**
- * @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,
-        notify: true,
-        value: false,
-      },
-      _name: Object,
-      _groupCreated: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateGroupName(_name)',
-    ];
-  }
-
-  _computeGroupUrl(groupId) {
-    return this.getBaseUrl() + '/admin/groups/' +
-        this.encodeURL(groupId, true);
-  }
-
-  _updateGroupName(name) {
-    this.hasNewGroupName = !!name;
-  }
-
-  handleCreateGroup() {
-    return this.$.restAPI.createGroup({name: this._name})
-        .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.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
new file mode 100644
index 0000000..1d8b7db
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -0,0 +1,82 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-create-group-dialog_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {page} from '../../../utils/page-wrapper-utils';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GroupName} from '../../../types/common';
+
+export interface GrCreateGroupDialog {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-create-group-dialog')
+export class GrCreateGroupDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, notify: true})
+  hasNewGroupName = false;
+
+  @property({type: String})
+  _name: GroupName | '' = '';
+
+  @property({type: Boolean})
+  _groupCreated = false;
+
+  _computeGroupUrl(groupId: string) {
+    return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
+  }
+
+  @observe('_name')
+  _updateGroupName(name: string) {
+    this.hasNewGroupName = !!name;
+  }
+
+  handleCreateGroup() {
+    const name = this._name as GroupName;
+    return this.$.restAPI.createGroup({name}).then(groupRegistered => {
+      if (groupRegistered.status !== 201) {
+        return;
+      }
+      this._groupCreated = true;
+      return this.$.restAPI.getGroupConfig(name).then(group => {
+        // TODO(TS): should group always defined ?
+        page.show(this._computeGroupUrl(group!.group_id!));
+      });
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-group-dialog': 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
deleted file mode 100644
index bc1f24c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
new file mode 100644
index 0000000..d4ecc5d
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 164db53..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
+++ /dev/null
@@ -1,100 +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">
-<meta charset="utf-8">
-<title>gr-create-group-dialog</title>
-
-<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>
-    <gr-create-group-dialog></gr-create-group-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-create-group-dialog.js';
-import page from 'page/page.mjs';
-
-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();
-  });
-
-  test('name is updated correctly', done => {
-    assert.isFalse(element.hasNewGroupName);
-
-    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();
-        });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
new file mode 100644
index 0000000..d32ff30
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
@@ -0,0 +1,79 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-create-group-dialog.js';
+import {page} from '../../../utils/page-wrapper-utils.js';
+
+const basicFixture = fixtureFromElement('gr-create-group-dialog');
+
+suite('gr-create-group-dialog tests', () => {
+  let element;
+
+  const GROUP_NAME = 'test-group';
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('name is updated correctly', done => {
+    assert.isFalse(element.hasNewGroupName);
+
+    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 => {
+    sinon.stub(element.$.restAPI, 'createGroup')
+        .returns(Promise.resolve({status: 201}));
+
+    sinon.stub(element.$.restAPI, 'getGroupConfig')
+        .returns(Promise.resolve({group_id: 551}));
+
+    const showStub = sinon.stub(page, 'show');
+    element.handleCreateGroup()
+        .then(() => {
+          assert.isTrue(showStub.calledWith('/admin/groups/551'));
+          done();
+        });
+  });
+
+  test('test for unsuccessful group creation', done => {
+    sinon.stub(element.$.restAPI, 'createGroup')
+        .returns(Promise.resolve({status: 409}));
+
+    sinon.stub(element.$.restAPI, 'getGroupConfig')
+        .returns(Promise.resolve({group_id: 551}));
+
+    const showStub = sinon.stub(page, 'show');
+    element.handleCreateGroup()
+        .then(() => {
+          assert.isFalse(showStub.called);
+          done();
+        });
+  });
+});
+
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
deleted file mode 100644
index 72a6b99..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
+++ /dev/null
@@ -1,115 +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.
- */
-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-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';
-
-const DETAIL_TYPES = {
-  branches: 'branches',
-  tags: 'tags',
-};
-
-/**
- * @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: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
-      itemDetail: String,
-      _itemName: String,
-      _itemRevision: String,
-      _itemAnnotation: String,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateItemName(_itemName)',
-    ];
-  }
-
-  _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';
-    }
-  }
-
-  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' : '';
-  }
-}
-
-customElements.define(GrCreatePointerDialog.is, GrCreatePointerDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
new file mode 100644
index 0000000..e0a5042
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -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.
+ */
+import '@polymer/iron-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-create-pointer-dialog_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {page} from '../../../utils/page-wrapper-utils';
+import {customElement, property, observe} from '@polymer/decorators';
+import {BranchName, RepoName} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+enum DetailType {
+  branches = 'branches',
+  tags = 'tags',
+}
+
+export interface GrCreatePointerDialog {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-create-pointer-dialog')
+export class GrCreatePointerDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  detailType?: string;
+
+  @property({type: String})
+  repoName?: RepoName;
+
+  @property({type: Boolean, notify: true})
+  hasNewItemName = false;
+
+  @property({type: String})
+  itemDetail?: DetailType;
+
+  @property({type: String})
+  _itemName?: BranchName;
+
+  @property({type: String})
+  _itemRevision?: string;
+
+  @property({type: String})
+  _itemAnnotation?: string;
+
+  @observe('_itemName')
+  _updateItemName(name?: string) {
+    this.hasNewItemName = !!name;
+  }
+
+  handleCreateItem() {
+    if (!this.repoName) {
+      throw new Error('repoName name is not set');
+    }
+    if (!this._itemName) {
+      throw new Error('itemName name is not set');
+    }
+    const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
+    const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
+    if (this.itemDetail === DetailType.branches) {
+      return this.$.restAPI
+        .createRepoBranch(this.repoName, this._itemName, {revision: USE_HEAD})
+        .then(itemRegistered => {
+          if (itemRegistered.status === 201) {
+            page.show(`${url},branches`);
+          }
+        });
+    } else if (this.itemDetail === DetailType.tags) {
+      return this.$.restAPI
+        .createRepoTag(this.repoName, this._itemName, {
+          revision: USE_HEAD,
+          message: this._itemAnnotation || undefined,
+        })
+        .then(itemRegistered => {
+          if (itemRegistered.status === 201) {
+            page.show(`${url},tags`);
+          }
+        });
+    }
+    throw new Error(`Invalid itemDetail: ${this.itemDetail}`);
+  }
+
+  _computeHideItemClass(type: DetailType) {
+    return type === DetailType.branches ? 'hideItem' : '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-pointer-dialog': 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
deleted file mode 100644
index 62a2e0f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
new file mode 100644
index 0000000..0b3d81ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 2778d40..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
+++ /dev/null
@@ -1,135 +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">
-<meta charset="utf-8">
-<title>gr-create-pointer-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-create-pointer-dialog></gr-create-pointer-dialog>
-  </template>
-</test-fixture>
-
-<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;
-
-  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();
-  });
-
-  test('branch created', done => {
-    sandbox.stub(
-        element.$.restAPI,
-        'createRepoBranch',
-        () => Promise.resolve({}));
-
-    assert.isFalse(element.hasNewItemName);
-
-    element._itemName = 'test-branch';
-    element.itemDetail = 'branches';
-
-    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',
-        () => 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-pointer-dialog/gr-create-pointer-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
new file mode 100644
index 0000000..79a18d5
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
@@ -0,0 +1,114 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-create-pointer-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
+
+suite('gr-create-pointer-dialog tests', () => {
+  let element;
+
+  const ironInput = function(element) {
+    return element.querySelector('iron-input');
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('branch created', done => {
+    sinon.stub(
+        element.$.restAPI,
+        'createRepoBranch')
+        .callsFake(() => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-branch';
+    element.itemDetail = 'branches';
+
+    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 => {
+    sinon.stub(
+        element.$.restAPI,
+        'createRepoTag')
+        .callsFake(() => 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 => {
+    sinon.stub(
+        element.$.restAPI,
+        'createRepoTag')
+        .callsFake(() => 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'), '');
+  });
+});
+
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
deleted file mode 100644
index 040f41b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
+++ /dev/null
@@ -1,157 +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.
- */
-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-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';
-
-/**
- * @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,
-        notify: true,
-        value: false,
-      },
-
-      /** @type {?} */
-      _repoConfig: {
-        type: Object,
-        value: () => {
-        // Set default values for dropdowns.
-          return {
-            create_empty_commit: true,
-            permissions_only: false,
-          };
-        },
-      },
-      _repoCreated: {
-        type: Boolean,
-        value: false,
-      },
-      _repoOwner: String,
-      _repoOwnerId: {
-        type: String,
-        observer: '_repoOwnerIdUpdate',
-      },
-
-      _query: {
-        type: Function,
-        value() {
-          return this._getRepoSuggestions.bind(this);
-        },
-      },
-      _queryGroups: {
-        type: Function,
-        value() {
-          return this._getGroupSuggestions.bind(this);
-        },
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateRepoName(_repoConfig.name)',
-    ];
-  }
-
-  _computeRepoUrl(repoName) {
-    return this.getBaseUrl() + '/admin/repos/' +
-        this.encodeURL(repoName, true);
-  }
-
-  _updateRepoName(name) {
-    this.hasNewRepoName = !!name;
-  }
-
-  _repoOwnerIdUpdate(id) {
-    if (id) {
-      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));
-          }
-        });
-  }
-
-  _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;
-        });
-  }
-}
-
-customElements.define(GrCreateRepoDialog.is, GrCreateRepoDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
new file mode 100644
index 0000000..6f0ac19
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -0,0 +1,145 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-create-repo-dialog_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {page} from '../../../utils/page-wrapper-utils';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ProjectInput, RepoName} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-repo-dialog': GrCreateRepoDialog;
+  }
+}
+
+export interface GrCreateRepoDialog {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-create-repo-dialog')
+export class GrCreateRepoDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, notify: true})
+  hasNewRepoName = false;
+
+  @property({type: Object})
+  _repoConfig: ProjectInput & {name: RepoName} = {
+    create_empty_commit: true,
+    permissions_only: false,
+    name: '' as RepoName,
+  };
+
+  @property({type: Boolean})
+  _repoCreated = false;
+
+  @property({type: String})
+  _repoOwner?: string;
+
+  @property({type: String})
+  _repoOwnerId?: string;
+
+  @property({type: Object})
+  _query: AutocompleteQuery;
+
+  @property({type: Object})
+  _queryGroups: AutocompleteQuery;
+
+  constructor() {
+    super();
+    this._query = (input: string) => this._getRepoSuggestions(input);
+    this._queryGroups = (input: string) => this._getGroupSuggestions(input);
+  }
+
+  _computeRepoUrl(repoName: string) {
+    return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
+  }
+
+  @observe('_repoConfig.name')
+  _updateRepoName(name: string) {
+    this.hasNewRepoName = !!name;
+  }
+
+  @observe('_repoOwnerId')
+  _repoOwnerIdUpdate(id?: string) {
+    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));
+      }
+    });
+  }
+
+  _getRepoSuggestions(input: string) {
+    return this.$.restAPI.getSuggestedProjects(input).then(response => {
+      const repos = [];
+      for (const key in response) {
+        if (!hasOwnProperty(response, key)) {
+          continue;
+        }
+        repos.push({
+          name: key,
+          value: response[key],
+        });
+      }
+      return repos;
+    });
+  }
+
+  _getGroupSuggestions(input: string) {
+    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+      const groups = [];
+      for (const key in response) {
+        if (!hasOwnProperty(response, key)) {
+          continue;
+        }
+        groups.push({
+          name: key,
+          value: decodeURIComponent(response[key].id),
+        });
+      }
+      return groups;
+    });
+  }
+}
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
deleted file mode 100644
index 680986c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
new file mode 100644
index 0000000..02aabfe
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index dfab4ac..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
+++ /dev/null
@@ -1,105 +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">
-<meta charset="utf-8">
-<title>gr-create-repo-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-create-repo-dialog></gr-create-repo-dialog>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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'],
-    };
-
-    const saveStub = sandbox.stub(element.$.restAPI,
-        'createRepo', () => Promise.resolve({}));
-
-    assert.isFalse(element.hasNewRepoName);
-
-    element._repoConfig = {
-      name: 'test-repo',
-      create_empty_commit: true,
-      parent: 'All-Project',
-      permissions_only: false,
-    };
-
-    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;
-
-    assert.isTrue(element.hasNewRepoName);
-
-    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']);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
new file mode 100644
index 0000000..1e1fb0e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
@@ -0,0 +1,85 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-create-repo-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-create-repo-dialog');
+
+suite('gr-create-repo-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  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'],
+    };
+
+    const saveStub = sinon.stub(element.$.restAPI,
+        'createRepo').callsFake(() => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewRepoName);
+
+    element._repoConfig = {
+      name: 'test-repo',
+      create_empty_commit: true,
+      parent: 'All-Project',
+      permissions_only: false,
+    };
+
+    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;
+
+    assert.isTrue(element.hasNewRepoName);
+
+    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']);
+  });
+});
+
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
deleted file mode 100644
index a3c05cb..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
+++ /dev/null
@@ -1,142 +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.
- */
-
-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';
-
-const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
-
-/**
- * @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,
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Audit Log'},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._getAuditLogs();
-  }
-
-  _getAuditLogs() {
-    if (!this.groupId) { return ''; }
-
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-
-    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';
-  }
-
-  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;
-  }
-
-  _computeGroupUrl(group) {
-    if (group && group.url && group.id) {
-      return GerritNav.getUrlForGroup(group.id);
-    }
-
-    return '';
-  }
-
-  _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);
-    }
-
-    return '';
-  }
-}
-
-customElements.define(GrGroupAuditLog.is, GrGroupAuditLog);
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
new file mode 100644
index 0000000..f7cffac
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -0,0 +1,159 @@
+/**
+ * @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 '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-account-link/gr-account-link';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-group-audit-log_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  GroupInfo,
+  AccountInfo,
+  EncodedGroupId,
+  GroupAuditEventInfo,
+} from '../../../types/common';
+
+const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
+
+export interface GrGroupAuditLog {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-group-audit-log')
+export class GrGroupAuditLog extends ListViewMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  groupId?: EncodedGroupId;
+
+  @property({type: Array})
+  _auditLog?: GroupAuditEventInfo[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: 'Audit Log'},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._getAuditLogs();
+  }
+
+  _getAuditLogs() {
+    if (!this.groupId) {
+      return '';
+    }
+
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+
+    return this.$.restAPI
+      .getGroupAuditLog(this.groupId, errFn)
+      .then(auditLog => {
+        if (!auditLog) {
+          this._auditLog = [];
+          return;
+        }
+        this._auditLog = auditLog;
+        this._loading = false;
+      });
+  }
+
+  itemType(type: string) {
+    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: string) {
+    return GROUP_EVENTS.indexOf(type) !== -1;
+  }
+
+  _computeGroupUrl(group: GroupInfo) {
+    if (group && group.url && group.id) {
+      return GerritNav.getUrlForGroup(group.id);
+    }
+
+    return '';
+  }
+
+  _getIdForUser(account: AccountInfo) {
+    return account._account_id ? ` (${account._account_id})` : '';
+  }
+
+  _getNameForGroup(group: GroupInfo) {
+    if (group && group.name) {
+      return group.name;
+    } else if (group && group.id) {
+      // The URL encoded id of the member
+      return decodeURIComponent(group.id);
+    }
+
+    return '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group-audit-log': 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
deleted file mode 100644
index 130efbb..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
new file mode 100644
index 0000000..1212685
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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
deleted file mode 100644
index 4590220..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
+++ /dev/null
@@ -1,116 +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">
-<meta charset="utf-8">
-<title>gr-group-audit-log</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-group-audit-log></gr-group-audit-log>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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');
-    });
-
-    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();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
new file mode 100644
index 0000000..1bbfcae
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
@@ -0,0 +1,97 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-group-audit-log.js';
+
+const basicFixture = fixtureFromElement('gr-group-audit-log');
+
+suite('gr-group-audit-log tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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};
+      sinon.stub(
+          element.$.restAPI, 'getGroupAuditLog')
+          .callsFake((group, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element._getAuditLogs();
+    });
+  });
+});
+
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
deleted file mode 100644
index 45f7612..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ /dev/null
@@ -1,313 +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.
- */
-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 URL_REGEX = '^(?:[a-z]+:)?//';
-
-/**
- * @extends Polymer.Element
- */
-class GrGroupMembers extends mixinBehaviors( [
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-group-members'; }
-
-  static get properties() {
-    return {
-      groupId: Number,
-      _groupMemberSearchId: String,
-      _groupMemberSearchName: String,
-      _includedGroupSearchId: String,
-      _includedGroupSearchName: String,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _groupName: String,
-      _groupMembers: Object,
-      _includedGroups: Object,
-      _itemName: String,
-      _itemType: String,
-      _queryMembers: {
-        type: Function,
-        value() {
-          return this._getAccountSuggestions.bind(this);
-        },
-      },
-      _queryIncludedGroup: {
-        type: Function,
-        value() {
-          return this._getGroupSuggestions.bind(this);
-        },
-      },
-      _groupOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadGroupDetails();
-
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Members'},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _loadGroupDetails() {
-    if (!this.groupId) { return; }
-
-    const promises = [];
-
-    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(); }
-
-          this._groupName = config.name;
-
-          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,
-            }));
-            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.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
new file mode 100644
index 0000000..ae10c03
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -0,0 +1,397 @@
+/**
+ * @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-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-group-members_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+  RestApiService,
+  ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {
+  GroupId,
+  AccountId,
+  AccountInfo,
+  GroupInfo,
+  GroupName,
+} from '../../../types/common';
+import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+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]+:)?//';
+
+export interface GrGroupMembers {
+  $: {
+    restAPI: RestApiService & Element;
+    overlay: GrOverlay;
+  };
+}
+@customElement('gr-group-members')
+export class GrGroupMembers extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Number})
+  groupId?: GroupId;
+
+  @property({type: Number})
+  _groupMemberSearchId?: number;
+
+  @property({type: String})
+  _groupMemberSearchName?: string;
+
+  @property({type: String})
+  _includedGroupSearchId?: string;
+
+  @property({type: String})
+  _includedGroupSearchName?: string;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _groupName?: GroupName;
+
+  @property({type: Object})
+  _groupMembers?: AccountInfo[];
+
+  @property({type: Object})
+  _includedGroups?: GroupInfo[];
+
+  @property({type: String})
+  _itemName?: GroupInfo | AccountInfo;
+
+  @property({type: String})
+  _itemType?: string;
+
+  @property({type: Object})
+  _queryMembers: AutocompleteQuery;
+
+  @property({type: Object})
+  _queryIncludedGroup: AutocompleteQuery;
+
+  @property({type: Boolean})
+  _groupOwner = false;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  _itemId?: AccountId | GroupId;
+
+  constructor() {
+    super();
+    this._queryMembers = input => this._getAccountSuggestions(input);
+    this._queryIncludedGroup = input => this._getGroupSuggestions(input);
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadGroupDetails();
+
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: 'Members'},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _loadGroupDetails() {
+    if (!this.groupId) {
+      return;
+    }
+
+    const promises: Promise<void>[] = [];
+
+    const errFn: ErrorCallback = 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();
+      }
+
+      this._groupName = config.name;
+
+      promises.push(
+        this.$.restAPI.getIsAdmin().then(isAdmin => {
+          this._isAdmin = !!isAdmin;
+        })
+      );
+
+      promises.push(
+        this.$.restAPI.getIsGroupOwner(this._groupName).then(isOwner => {
+          this._groupOwner = !!isOwner;
+        })
+      );
+
+      promises.push(
+        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+          this._groupMembers = members;
+        })
+      );
+
+      promises.push(
+        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
+          this._includedGroups = includedGroup;
+        })
+      );
+
+      return Promise.all(promises).then(() => {
+        this._loading = false;
+      });
+    });
+  }
+
+  _computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _computeGroupUrl(url: string) {
+    if (!url) {
+      return;
+    }
+
+    const r = new RegExp(URL_REGEX, 'i');
+    if (r.test(url)) {
+      return url;
+    }
+
+    // For GWT compatibility
+    if (url.startsWith('#')) {
+      return getBaseUrl() + url.slice(1);
+    }
+    return getBaseUrl() + url;
+  }
+
+  _handleSavingGroupMember() {
+    if (!this._groupName) {
+      return Promise.reject(new Error('group name undefined'));
+    }
+    return this.$.restAPI
+      .saveGroupMember(this._groupName, this._groupMemberSearchId as AccountId)
+      .then(config => {
+        if (!config || !this._groupName) {
+          return;
+        }
+        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+          this._groupMembers = members;
+        });
+        this._groupMemberSearchName = '';
+        this._groupMemberSearchId = undefined;
+      });
+  }
+
+  _handleDeleteConfirm() {
+    if (!this._groupName) {
+      return Promise.reject(new Error('group name undefined'));
+    }
+    this.$.overlay.close();
+    if (this._itemType === 'member') {
+      return this.$.restAPI
+        .deleteGroupMember(this._groupName, this._itemId! as AccountId)
+        .then(itemDeleted => {
+          if (itemDeleted.status === 204 && this._groupName) {
+            this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+              this._groupMembers = members;
+            });
+          }
+        });
+    } else if (this._itemType === 'includedGroup') {
+      return this.$.restAPI
+        .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
+        .then(itemDeleted => {
+          if (
+            (itemDeleted.status === 204 || itemDeleted.status === 205) &&
+            this._groupName
+          ) {
+            this.$.restAPI
+              .getIncludedGroup(this._groupName)
+              .then(includedGroup => {
+                this._includedGroups = includedGroup;
+              });
+          }
+        });
+    }
+    return Promise.reject(new Error('Unrecognized item type'));
+  }
+
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteMember(e: PolymerDomRepeatEvent<AccountInfo>) {
+    const id = (e.model.get('item._account_id') as unknown) as AccountId;
+    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() {
+    if (!this._groupName || !this._includedGroupSearchId) {
+      return Promise.reject(
+        new Error('group name or includedGroupSearchId undefined')
+      );
+    }
+    return this.$.restAPI
+      .saveIncludedGroup(
+        this._groupName,
+        this._includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
+        (errResponse, err) => {
+          if (errResponse) {
+            if (errResponse.status === 404) {
+              this.dispatchEvent(
+                new CustomEvent('show-alert', {
+                  detail: {message: SAVING_ERROR_TEXT},
+                  bubbles: true,
+                  composed: true,
+                })
+              );
+              return errResponse;
+            }
+            throw Error(errResponse.statusText);
+          }
+          throw err;
+        }
+      )
+      .then(config => {
+        if (!config || !this._groupName) {
+          return;
+        }
+        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
+          this._includedGroups = includedGroup;
+        });
+        this._includedGroupSearchName = '';
+        this._includedGroupSearchId = '';
+      });
+  }
+
+  _handleDeleteIncludedGroup(e: PolymerDomRepeatEvent<GroupInfo>) {
+    const id = decodeURIComponent(`${e.model.get('item.id')}`).replace(
+      /\+/g,
+      ' '
+    ) as GroupId;
+    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: string) {
+    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 (!hasOwnProperty(accounts, 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: string) {
+    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+      const groups = [];
+      for (const key in response) {
+        if (!hasOwnProperty(response, key)) {
+          continue;
+        }
+        groups.push({
+          name: key,
+          value: decodeURIComponent(response[key].id),
+        });
+      }
+      return groups;
+    });
+  }
+
+  _computeHideItemClass(owner: boolean, admin: boolean) {
+    return admin || owner ? '' : 'canModify';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group-members': 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
deleted file mode 100644
index c5577c2..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
new file mode 100644
index 0000000..2d3f8fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
@@ -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';
+
+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" class="heading-1">[[_groupName]]</h1>
+      <div id="form">
+        <h3 id="members" class="heading-3">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" class="heading-3">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
deleted file mode 100644
index 4dd9a7b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
+++ /dev/null
@@ -1,374 +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">
-<meta charset="utf-8">
-<title>gr-group-members</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-group-members></gr-group-members>
-  </template>
-</test-fixture>
-
-<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();
-
-    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',
-    },
-    {
-      url: '/group/url',
-      options: {},
-      id: 'testId2',
-      name: 'testName2',
-    },
-    {
-      url: '#/group/url',
-      options: {},
-      id: 'testId3',
-      name: 'testName3',
-    },
-    ];
-
-    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();
-  });
-
-  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'));
-      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-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
new file mode 100644
index 0000000..b5d2217
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -0,0 +1,382 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-group-members.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-group-members');
+
+suite('gr-group-members tests', () => {
+  let element;
+
+  let groups;
+  let groupMembers;
+  let includedGroups;
+  let groupStub;
+
+  setup(() => {
+    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',
+    },
+    {
+      url: '/group/url',
+      options: {},
+      id: 'testId2',
+      name: 'testName2',
+    },
+    {
+      url: '#/group/url',
+      options: {},
+      id: 'testId3',
+      name: 'testName3',
+    },
+    ];
+
+    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 = basicFixture.instantiate();
+    stubBaseUrl('https://test/site');
+    element.groupId = 1;
+    groupStub = sinon.stub(
+        element.$.restAPI,
+        'getGroupConfig')
+        .callsFake(() => Promise.resolve(groups));
+    return element._loadGroupDetails();
+  });
+
+  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 = sinon.stub(element.$.restAPI, 'saveGroupMember')
+        .callsFake(() => 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'));
+      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 = sinon.stub(
+        element.$.restAPI, 'saveIncludedGroup')
+        .callsFake(() => 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;
+    element._groupName = 'test';
+
+    const memberName = 'bad-name';
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    const errorResponse = {
+      status: 404,
+      ok: false,
+    };
+    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
+        () => Promise.resolve(errorResponse));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    return flush(element._handleSavingIncludedGroups().then(() => {
+      assert.isTrue(alertStub.called);
+    }));
+  });
+
+  test('add included group network-error throws an exception', async () => {
+    element._groupOwner = true;
+
+    const memberName = 'bad-name';
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    const err = new Error();
+    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
+        () => Promise.reject(err));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    let exceptionThrown = false;
+    try {
+      await element._handleSavingIncludedGroups();
+    } catch (e) {
+      exceptionThrown = true;
+    }
+    assert.isTrue(exceptionThrown);
+  });
+
+  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 deleteBtns = dom(element.root)
+        .querySelectorAll('.deleteMembersButton');
+    MockInteractions.tap(deleteBtns[0]);
+    assert.equal(element._itemId, '1000097');
+    assert.equal(element._itemName, 'jane');
+    MockInteractions.tap(deleteBtns[1]);
+    assert.equal(element._itemId, '1000096');
+    assert.equal(element._itemName, 'Test User');
+    MockInteractions.tap(deleteBtns[2]);
+    assert.equal(element._itemId, '1000095');
+    assert.equal(element._itemName, 'Gerrit');
+    MockInteractions.tap(deleteBtns[3]);
+    assert.equal(element._itemId, '1000098');
+    assert.equal(element._itemName, '1000098');
+  });
+
+  test('delete included groups', () => {
+    const deleteBtns = dom(element.root)
+        .querySelectorAll('.deleteIncludedGroupButton');
+    MockInteractions.tap(deleteBtns[0]);
+    assert.equal(element._itemId, 'testId');
+    assert.equal(element._itemName, 'testName');
+    MockInteractions.tap(deleteBtns[1]);
+    assert.equal(element._itemId, 'testId2');
+    assert.equal(element._itemName, 'testName2');
+    MockInteractions.tap(deleteBtns[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};
+    sinon.stub(
+        element.$.restAPI, 'getGroupConfig')
+        .callsFake((group, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element._loadGroupDetails();
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
deleted file mode 100644
index 1c7cc91..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
+++ /dev/null
@@ -1,275 +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.
- */
-
-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 INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
-
-const OPTIONS = {
-  submitFalse: {
-    value: false,
-    label: 'False',
-  },
-  submitTrue: {
-    value: true,
-    label: 'True',
-  },
-};
-
-/**
- * @extends Polymer.Element
- */
-class GrGroup extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-group'; }
-  /**
-   * Fired when the group name changes.
-   *
-   * @event name-changed
-   */
-
-  static get properties() {
-    return {
-      groupId: Number,
-      _rename: {
-        type: Boolean,
-        value: false,
-      },
-      _groupIsInternal: Boolean,
-      _description: {
-        type: Boolean,
-        value: false,
-      },
-      _owner: {
-        type: Boolean,
-        value: false,
-      },
-      _options: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {?} */
-      _groupConfig: Object,
-      _groupConfigOwner: String,
-      _groupName: Object,
-      _groupOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _submitTypes: {
-        type: Array,
-        value() {
-          return Object.values(OPTIONS);
-        },
-      },
-      _query: {
-        type: Function,
-        value() {
-          return this._getGroupSuggestions.bind(this);
-        },
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handleConfigName(_groupConfig.name)',
-      '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
-      '_handleConfigDescription(_groupConfig.description)',
-      '_handleConfigOptions(_groupConfig.options.visible_to_all)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadGroup();
-  }
-
-  _loadGroup() {
-    if (!this.groupId) { return; }
-
-    const promises = [];
-
-    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(); }
-
-          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.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;
-          }
-        });
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
new file mode 100644
index 0000000..511bf5c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -0,0 +1,334 @@
+/**
+ * @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-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-group_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+  AutocompleteSuggestion,
+  AutocompleteQuery,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
+import {
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+
+const OPTIONS = {
+  submitFalse: {
+    value: false,
+    label: 'False',
+  },
+  submitTrue: {
+    value: true,
+    label: 'True',
+  },
+};
+
+export interface GrGroup {
+  $: {
+    restAPI: RestApiService & Element;
+    loading: HTMLDivElement;
+  };
+}
+
+export interface GroupNameChangedDetail {
+  name: GroupName;
+  external: boolean;
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group': GrGroup;
+  }
+}
+
+@customElement('gr-group')
+export class GrGroup extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the group name changes.
+   *
+   * @event name-changed
+   */
+
+  @property({type: String})
+  groupId?: GroupId;
+
+  @property({type: Boolean})
+  _rename = false;
+
+  @property({type: Boolean})
+  _groupIsInternal = false;
+
+  @property({type: Boolean})
+  _description = false;
+
+  @property({type: Boolean})
+  _owner = false;
+
+  @property({type: Boolean})
+  _options = false;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Object})
+  _groupConfig?: GroupInfo;
+
+  @property({type: String})
+  _groupConfigOwner?: string;
+
+  @property({type: Object})
+  _groupName?: string;
+
+  @property({type: Boolean})
+  _groupOwner = false;
+
+  @property({type: Array})
+  _submitTypes = Object.values(OPTIONS);
+
+  @property({type: Object})
+  _query: AutocompleteQuery;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  constructor() {
+    super();
+    this._query = (input: string) => this._getGroupSuggestions(input);
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadGroup();
+  }
+
+  _loadGroup() {
+    if (!this.groupId) {
+      return;
+    }
+
+    const promises: Promise<unknown>[] = [];
+
+    const errFn: ErrorCallback = 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();
+      }
+
+      this._groupName = config.name;
+      this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+
+      promises.push(
+        this.$.restAPI.getIsAdmin().then(isAdmin => {
+          this._isAdmin = !!isAdmin;
+        })
+      );
+
+      promises.push(
+        this.$.restAPI.getIsGroupOwner(config.name).then(isOwner => {
+          this._groupOwner = !!isOwner;
+        })
+      );
+
+      // 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 && !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: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _handleSaveName() {
+    const groupConfig = this._groupConfig;
+    if (!this.groupId || !groupConfig || !groupConfig.name) {
+      return Promise.reject(new Error('invalid groupId or config name'));
+    }
+    const groupName = groupConfig.name;
+    return this.$.restAPI
+      .saveGroupName(this.groupId, groupName)
+      .then(config => {
+        if (config.status === 200) {
+          this._groupName = groupName;
+          const detail: GroupNameChangedDetail = {
+            name: groupName,
+            external: !this._groupIsInternal,
+          };
+          this.dispatchEvent(
+            new CustomEvent('name-changed', {
+              detail,
+              composed: true,
+              bubbles: true,
+            })
+          );
+          this._rename = false;
+        }
+      });
+  }
+
+  _handleSaveOwner() {
+    if (!this.groupId || !this._groupConfig) return;
+    let owner = this._groupConfig.owner;
+    if (this._groupConfigOwner) {
+      owner = decodeURIComponent(this._groupConfigOwner);
+    }
+    if (!owner) return;
+    return this.$.restAPI.saveGroupOwner(this.groupId, owner).then(() => {
+      this._owner = false;
+    });
+  }
+
+  _handleSaveDescription() {
+    if (!this.groupId || !this._groupConfig || !this._groupConfig.description)
+      return;
+    return this.$.restAPI
+      .saveGroupDescription(this.groupId, this._groupConfig.description)
+      .then(() => {
+        this._description = false;
+      });
+  }
+
+  _handleSaveOptions() {
+    if (!this.groupId || !this._groupConfig || !this._groupConfig.options)
+      return;
+    const visible = this._groupConfig.options.visible_to_all;
+
+    const options = {visible_to_all: visible};
+
+    return this.$.restAPI.saveGroupOptions(this.groupId, options).then(() => {
+      this._options = false;
+    });
+  }
+
+  @observe('_groupConfig.name')
+  _handleConfigName() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._rename = true;
+  }
+
+  @observe('_groupConfig.owner', '_groupConfigOwner')
+  _handleConfigOwner() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._owner = true;
+  }
+
+  @observe('_groupConfig.description')
+  _handleConfigDescription() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._description = true;
+  }
+
+  @observe('_groupConfig.options.visible_to_all')
+  _handleConfigOptions() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._options = true;
+  }
+
+  _computeHeaderClass(configChanged: boolean) {
+    return configChanged ? 'edited' : '';
+  }
+
+  _getGroupSuggestions(input: string) {
+    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+      const groups: AutocompleteSuggestion[] = [];
+      for (const key in response) {
+        if (!hasOwnProperty(response, key)) {
+          continue;
+        }
+        groups.push({
+          name: key,
+          value: decodeURIComponent(response[key].id),
+        });
+      }
+      return groups;
+    });
+  }
+
+  _computeGroupDisabled(
+    owner: boolean,
+    admin: boolean,
+    groupIsInternal: boolean
+  ) {
+    return !(groupIsInternal && (admin || owner));
+  }
+
+  _getGroupUUID(id: GroupId) {
+    if (!id) return;
+
+    return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
+  }
+}
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
deleted file mode 100644
index e11f989..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
+++ /dev/null
@@ -1,163 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
new file mode 100644
index 0000000..aed73bf
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
@@ -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';
+
+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" class="heading-1">[[_groupName]]</h1>
+      <h2 id="configurations" class="heading-2">General</h2>
+      <div id="form">
+        <fieldset>
+          <h3 id="groupUUID" class="heading-3">Group UUID</h3>
+          <fieldset>
+            <gr-copy-clipboard
+              id="uuid"
+              text="[[_getGroupUUID(_groupConfig.id)]]"
+            ></gr-copy-clipboard>
+          </fieldset>
+          <h3
+            id="groupName"
+            class$="heading-3 [[_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$="heading-3 [[_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$="heading-3 [[_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$="heading-3 [[_computeHeaderClass(_options)]]">
+            Group Options
+          </h3>
+          <fieldset>
+            <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
deleted file mode 100644
index 5621fff..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
+++ /dev/null
@@ -1,289 +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">
-<meta charset="utf-8">
-<title>gr-group</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-group></gr-group>
-  </template>
-</test-fixture>
-
-<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',
-        () => Promise.resolve(group)
-    );
-  });
-
-  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('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-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
new file mode 100644
index 0000000..34f6b6a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
@@ -0,0 +1,269 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-group.js';
+
+const basicFixture = fixtureFromElement('gr-group');
+
+suite('gr-group tests', () => {
+  let element;
+
+  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(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    element = basicFixture.instantiate();
+    groupStub = sinon.stub(
+        element.$.restAPI,
+        'getGroupConfig')
+        .callsFake(() => Promise.resolve(group));
+  });
+
+  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 => {
+    sinon.stub(
+        element.$.restAPI,
+        'getIsGroupOwner')
+        .callsFake(() => 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 = {...group};
+    groupExternal.id = 'external-group-id';
+    groupStub.restore();
+    groupStub = sinon.stub(
+        element.$.restAPI,
+        'getGroupConfig')
+        .callsFake(() => Promise.resolve(groupExternal));
+    sinon.stub(
+        element.$.restAPI,
+        'getIsGroupOwner')
+        .callsFake(() => 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;
+
+    sinon.stub(
+        element.$.restAPI,
+        'getIsGroupOwner')
+        .callsFake(() => Promise.resolve(true));
+
+    sinon.stub(
+        element.$.restAPI,
+        'saveGroupName')
+        .callsFake(() => 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;
+
+    sinon.stub(
+        element.$.restAPI,
+        'getIsGroupOwner')
+        .callsFake(() => 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();
+
+    sinon.stub(
+        element.$.restAPI,
+        'getGroupConfig')
+        .callsFake(() => 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',
+    };
+    element.groupId = 'gg';
+    sinon.stub(element.$.restAPI, 'saveGroupName')
+        .returns(Promise.resolve({status: 200}));
+
+    const showStub = sinon.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};
+    sinon.stub(
+        element.$.restAPI, 'getGroupConfig').callsFake((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);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
deleted file mode 100644
index 3f577ae..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ /dev/null
@@ -1,330 +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.
- */
-import '../../../scripts/bundled-polymer.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/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 MAX_AUTOCOMPLETE_RESULTS = 20;
-
-const RANGE_NAMES = [
-  'QUERY LIMIT',
-  'BATCH CHANGES LIMIT',
-];
-
-/**
- * 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; }
-
-  static get is() { return 'gr-permission'; }
-
-  static get properties() {
-    return {
-      repo: String,
-      labels: Object,
-      name: String,
-      /** @type {?} */
-      permission: {
-        type: Object,
-        observer: '_sortPermission',
-        notify: true,
-      },
-      groups: Object,
-      section: String,
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_handleEditingChanged',
-      },
-      _label: {
-        type: Object,
-        computed: '_computeLabel(permission, labels)',
-      },
-      _groupFilter: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getGroupSuggestions.bind(this);
-        },
-      },
-      _rules: Array,
-      _groupsWithRules: Object,
-      _deleted: {
-        type: Boolean,
-        value: false,
-      },
-      _originalExclusiveValue: Boolean,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handleRulesChanged(_rules.splices)',
-    ];
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('access-saved',
-        () => this._handleAccessSaved());
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._setupValues();
-  }
-
-  _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();
-  }
-
-  _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() {
-    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,
-        this.repo,
-        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] = {};
-
-    // 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};
-    }
-
-    // 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; }
-
-    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.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
new file mode 100644
index 0000000..9caebde
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -0,0 +1,443 @@
+/**
+ * @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/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-menu-page-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-rule-editor/gr-rule-editor';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-permission_html';
+import {
+  toSortedPermissionsArray,
+  PermissionArrayItem,
+  PermissionArray,
+} from '../../../utils/access-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  LabelNameToLabelTypeInfoMap,
+  LabelTypeInfoValues,
+  GroupInfo,
+  ProjectAccessGroups,
+  GroupId,
+  GitRef,
+  RepoName,
+} from '../../../types/common';
+import {
+  AutocompleteQuery,
+  GrAutocomplete,
+  AutocompleteSuggestion,
+  AutocompleteCommitEvent,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+  EditablePermissionInfo,
+  EditablePermissionRuleInfo,
+  EditableProjectAccessGroups,
+} from '../gr-repo-access/gr-repo-access-interfaces';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+
+const MAX_AUTOCOMPLETE_RESULTS = 20;
+
+const RANGE_NAMES = ['QUERY LIMIT', 'BATCH CHANGES LIMIT'];
+
+type GroupsWithRulesMap = {[ruleId: string]: boolean};
+
+export interface GrPermission {
+  $: {
+    restAPI: RestApiService & Element;
+    groupAutocomplete: GrAutocomplete;
+  };
+}
+
+interface ComputedLabelValue {
+  value: number;
+  text: string;
+}
+
+interface ComputedLabel {
+  name: string;
+  values: ComputedLabelValue[];
+}
+
+interface GroupSuggestion {
+  name: string;
+  value: GroupInfo;
+}
+
+/**
+ * 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
+ */
+@customElement('gr-permission')
+export class GrPermission extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  repo?: RepoName;
+
+  @property({type: Object})
+  labels?: LabelNameToLabelTypeInfoMap;
+
+  @property({type: String})
+  name?: string;
+
+  @property({type: Object, observer: '_sortPermission', notify: true})
+  permission?: PermissionArrayItem<EditablePermissionInfo>;
+
+  @property({type: Object})
+  groups?: EditableProjectAccessGroups;
+
+  @property({type: String})
+  section?: GitRef;
+
+  @property({type: Boolean, observer: '_handleEditingChanged'})
+  editing = false;
+
+  @property({type: Object, computed: '_computeLabel(permission, labels)'})
+  _label?: ComputedLabel;
+
+  @property({type: String})
+  _groupFilter?: string;
+
+  @property({type: Object})
+  _query: AutocompleteQuery;
+
+  @property({type: Array})
+  _rules?: PermissionArray<EditablePermissionRuleInfo>;
+
+  @property({type: Object})
+  _groupsWithRules?: GroupsWithRulesMap;
+
+  @property({type: Boolean})
+  _deleted = false;
+
+  @property({type: Boolean})
+  _originalExclusiveValue?: boolean;
+
+  constructor() {
+    super();
+    this._query = () => this._getGroupSuggestions();
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-saved', () => this._handleAccessSaved());
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._setupValues();
+  }
+
+  _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();
+  }
+
+  _permissionIsOwnerOrGlobal(permissionId: string, section: string) {
+    return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
+  }
+
+  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+    // Ignore when editing gets set initially.
+    if (!editingOld) {
+      return;
+    }
+    if (!this.permission || !this._rules) {
+      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: PolymerDomRepeatEvent) {
+    if (!this._rules) {
+      return;
+    }
+    const index = e.model.index;
+    this._rules = this._rules
+      .slice(0, index)
+      .concat(this._rules.slice(index + 1, this._rules.length));
+  }
+
+  _handleValueChange() {
+    if (!this.permission) {
+      return;
+    }
+    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) {
+      return;
+    }
+    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})
+    );
+  }
+
+  @observe('_rules.splices')
+  _handleRulesChanged() {
+    if (!this._rules) {
+      return;
+    }
+    // Update the groups to exclude in the autocomplete.
+    this._groupsWithRules = this._computeGroupsWithRules(this._rules);
+  }
+
+  _sortPermission(permission: PermissionArrayItem<EditablePermissionInfo>) {
+    this._rules = toSortedPermissionsArray(permission.value.rules);
+  }
+
+  _computeSectionClass(editing: boolean, deleted: boolean) {
+    const classList = [];
+    if (editing) {
+      classList.push('editing');
+    }
+    if (deleted) {
+      classList.push('deleted');
+    }
+    return classList.join(' ');
+  }
+
+  _handleUndoRemove() {
+    if (!this.permission) {
+      return;
+    }
+    this._deleted = false;
+    delete this.permission.value.deleted;
+  }
+
+  _computeLabel(
+    permission?: PermissionArrayItem<EditablePermissionInfo>,
+    labels?: LabelNameToLabelTypeInfoMap
+  ): ComputedLabel | undefined {
+    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;
+    }
+    return {
+      name: labelName,
+      values: this._computeLabelValues(labels[labelName].values),
+    };
+  }
+
+  _computeLabelValues(values: LabelTypeInfoValues): ComputedLabelValue[] {
+    const valuesArr: ComputedLabelValue[] = [];
+    const keys = Object.keys(values).sort((a, b) => Number(a) - Number(b));
+
+    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: Number(key), text});
+    }
+    return valuesArr;
+  }
+
+  _computeGroupsWithRules(
+    rules: PermissionArray<EditablePermissionRuleInfo>
+  ): GroupsWithRulesMap {
+    const groups: GroupsWithRulesMap = {};
+    for (const rule of rules) {
+      groups[rule.id] = true;
+    }
+    return groups;
+  }
+
+  _computeGroupName(groups: ProjectAccessGroups, groupId: GroupId) {
+    return groups && groups[groupId] && groups[groupId].name
+      ? groups[groupId].name
+      : groupId;
+  }
+
+  _getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
+    return this.$.restAPI
+      .getSuggestedGroups(
+        this._groupFilter || '',
+        this.repo,
+        MAX_AUTOCOMPLETE_RESULTS
+      )
+      .then(response => {
+        const groups: GroupSuggestion[] = [];
+        for (const key in response) {
+          if (!hasOwnProperty(response, 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 && !this._groupsWithRules[group.value.id]
+          )
+          .map((group: GroupSuggestion) => {
+            const autocompleteSuggestion: AutocompleteSuggestion = {
+              name: group.name,
+              value: group.value.id,
+            };
+            return autocompleteSuggestion;
+          });
+      });
+  }
+
+  /**
+   * Handles adding a skeleton item to the dom-repeat.
+   * gr-rule-editor handles setting the default values.
+   */
+  _handleAddRuleItem(e: AutocompleteCommitEvent) {
+    if (!this.permission || !this._rules) {
+      return;
+    }
+
+    // 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).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
+    // Actual value assigned below, after the flush
+    this.permission.value.rules[groupId] = {} as EditablePermissionRuleInfo;
+
+    // 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};
+    }
+
+    // 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 propagated
+    // 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: string) {
+    if (!name) {
+      return false;
+    }
+
+    return RANGE_NAMES.includes(name.toUpperCase());
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapExclusiveToggle(e: Event) {
+    e.preventDefault();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-permission': 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
deleted file mode 100644
index ed4f64a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
new file mode 100644
index 0000000..9795c92
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 1ce492e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
+++ /dev/null
@@ -1,434 +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">
-<meta charset="utf-8">
-<title>gr-permission</title>
-
-<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>
-    <gr-permission></gr-permission>
-  </template>
-</test-fixture>
-
-<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',
-          },
-          '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,
-            },
-            '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);
-    });
-
-    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: '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'));
-    });
-  });
-
-  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-permission/gr-permission_test.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
new file mode 100644
index 0000000..32430ec
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
@@ -0,0 +1,411 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-permission.js';
+
+const basicFixture = fixtureFromElement('gr-permission');
+
+suite('gr-permission tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    sinon.stub(element.$.restAPI, 'getSuggestedGroups').returns(
+        Promise.resolve({
+          'Administrators': {
+            id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+          },
+          'Anonymous Users': {
+            id: 'global%3AAnonymous-Users',
+          },
+        }));
+  });
+
+  suite('unit tests', () => {
+    test('_sortPermission', () => {
+      const permission = {
+        id: 'submit',
+        value: {
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+            },
+            '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);
+    });
+
+    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: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+          }, {
+            name: 'Anonymous Users',
+            value: 'global%3AAnonymous-Users',
+          },
+        ]);
+        done();
+      });
+    });
+
+    test('_getGroupSuggestions with existing rules filters them', done => {
+      element._groupsWithRules = {
+        '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
+      };
+
+      element._getGroupSuggestions().then(groups => {
+        assert.deepEqual(groups, [{
+          name: 'Anonymous Users',
+          value: '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'));
+    });
+  });
+
+  suite('interactions', () => {
+    setup(() => {
+      sinon.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();
+      flush();
+    });
+
+    test('adding a rule', () => {
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.groups = {};
+      element.$.groupAutocomplete.text = 'ldap/tests te.st';
+      const e = {
+        detail: {
+          value: '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);
+      flush();
+      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,
+              }));
+      flush();
+      assert.equal(element._rules.length, 1);
+    });
+
+    test('removing an added permission', () => {
+      const removeStub = sinon.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 = sinon.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'));
+      flush();
+      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 = sinon.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');
+      flush();
+      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';
+      flush();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'none');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
deleted file mode 100644
index 0e148a3..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @extends Polymer.Element */
-class GrPluginConfigArrayEditor extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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} */
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      /** @type {?} */
-      _newValue: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  _handleAddTap(e) {
-    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 = '';
-  }
-
-  _handleDelete(e) {
-    const value = 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}));
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
new file mode 100644
index 0000000..ac82547
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -0,0 +1,111 @@
+/**
+ * @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.
+ */
+import '@polymer/iron-input/iron-input';
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-plugin-config-array-editor_html';
+import {property, customElement} from '@polymer/decorators';
+import {
+  PluginConfigOptionsChangedEventDetail,
+  ArrayPluginOption,
+} from '../gr-repo-plugin-config/gr-repo-plugin-config-types';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-plugin-config-array-editor': GrPluginConfigArrayEditor;
+  }
+}
+
+@customElement('gr-plugin-config-array-editor')
+class GrPluginConfigArrayEditor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the plugin config option changes.
+   *
+   * @event plugin-config-option-changed
+   */
+
+  @property({type: String})
+  _newValue = '';
+
+  // This property is never null, since this component in only about operations
+  // on pluginOption.
+  @property({type: Object})
+  pluginOption!: ArrayPluginOption;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  _handleAddTap(e: MouseEvent) {
+    e.preventDefault();
+    this._handleAdd();
+  }
+
+  _handleInputKeydown(e: KeyboardEvent) {
+    // 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 = '';
+  }
+
+  _handleDelete(e: MouseEvent) {
+    const value = ((dom(e) as EventApi).localTarget as HTMLElement).dataset[
+      'item'
+    ];
+    this._dispatchChanged(
+      this.pluginOption.info.values.filter(str => str !== value)
+    );
+  }
+
+  _dispatchChanged(values: string[]) {
+    const {_key, info} = this.pluginOption;
+    const detail: PluginConfigOptionsChangedEventDetail = {
+      _key,
+      info: {...info, values},
+      notifyPath: `${_key}.values`,
+    };
+    this.dispatchEvent(
+      new CustomEvent('plugin-config-option-changed', {detail})
+    );
+  }
+
+  _computeShowInputRow(disabled: boolean) {
+    return disabled ? 'hide' : '';
+  }
+}
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
deleted file mode 100644
index 7a8da94..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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}}"
-          disabled$="[[disabled]]"
-        />
-      </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_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts
new file mode 100644
index 0000000..7709198
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts
@@ -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';
+
+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}}"
+          disabled$="[[disabled]]"
+        />
+      </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
deleted file mode 100644
index 42f1d2a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
+++ /dev/null
@@ -1,137 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-plugin-config-array-editor></gr-plugin-config-array-editor>
-  </template>
-</test-fixture>
-
-<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 => 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), '');
-  });
-
-  suite('adding', () => {
-    setup(() => {
-      dispatchStub = sandbox.stub(element, '_dispatchChanged');
-    });
-
-    test('with enter', () => {
-      element._newValue = '';
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-      assert.isFalse(element.$.input.hasAttribute('disabled'));
-      flushAsynchronousOperations();
-
-      assert.isFalse(dispatchStub.called);
-      element._newValue = 'test';
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-      assert.isFalse(element.$.input.hasAttribute('disabled'));
-      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']}};
-    element.disabled = true;
-    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.disabled = false;
-    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-config-array-editor/gr-plugin-config-array-editor_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
new file mode 100644
index 0000000..326ff44
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
@@ -0,0 +1,119 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-plugin-config-array-editor.js';
+
+const basicFixture = fixtureFromElement('gr-plugin-config-array-editor');
+
+suite('gr-plugin-config-array-editor tests', () => {
+  let element;
+
+  let dispatchStub;
+
+  const getAll = str => element.root.querySelectorAll(str);
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.pluginOption = {
+      _key: 'test-key',
+      info: {
+        values: [],
+      },
+    };
+  });
+
+  test('_computeShowInputRow', () => {
+    assert.equal(element._computeShowInputRow(true), 'hide');
+    assert.equal(element._computeShowInputRow(false), '');
+  });
+
+  suite('adding', () => {
+    setup(() => {
+      dispatchStub = sinon.stub(element, '_dispatchChanged');
+    });
+
+    test('with enter', () => {
+      element._newValue = '';
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+      assert.isFalse(element.$.input.hasAttribute('disabled'));
+      flush();
+
+      assert.isFalse(dispatchStub.called);
+      element._newValue = 'test';
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+      assert.isFalse(element.$.input.hasAttribute('disabled'));
+      flush();
+
+      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);
+      flush();
+
+      assert.isFalse(dispatchStub.called);
+      element._newValue = 'test';
+      MockInteractions.tap(element.$.addButton);
+      flush();
+
+      assert.isTrue(dispatchStub.called);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element._newValue, '');
+    });
+  });
+
+  test('deleting', () => {
+    dispatchStub = sinon.stub(element, '_dispatchChanged');
+    element.pluginOption = {info: {values: ['test', 'test2']}};
+    element.disabled = true;
+    flush();
+
+    const rows = getAll('.existingItems .row');
+    assert.equal(rows.length, 2);
+    const button = rows[0].querySelector('gr-button');
+
+    MockInteractions.tap(button);
+    flush();
+
+    assert.isFalse(dispatchStub.called);
+    element.disabled = false;
+    element.notifyPath('pluginOption.info.editable');
+    flush();
+
+    MockInteractions.tap(button);
+    flush();
+
+    assert.isTrue(dispatchStub.called);
+    assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+  });
+
+  test('_dispatchChanged', () => {
+    const eventStub = sinon.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');
+  });
+});
+
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
deleted file mode 100644
index 154af6e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
+++ /dev/null
@@ -1,138 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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',
-      },
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: {
-        type: Number,
-        value: 0,
-      },
-      _path: {
-        type: String,
-        readOnly: true,
-        value: '/admin/plugins',
-      },
-      _plugins: Array,
-      /**
-       * Because  we request one more than the pluginsPerPage, _shownPlugins
-       * maybe one less than _plugins.
-       * */
-      _shownPlugins: {
-        type: Array,
-        computed: 'computeShownItems(_plugins)',
-      },
-      _pluginsPerPage: {
-        type: Number,
-        value: 25,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Plugins'},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _paramsChanged(params) {
-    this._loading = true;
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
-
-    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;
-        });
-  }
-
-  _status(item) {
-    return item.disabled === true ? 'Disabled' : 'Enabled';
-  }
-
-  _computePluginUrl(id) {
-    return this.getUrl('/', id);
-  }
-}
-
-customElements.define(GrPluginList.is, GrPluginList);
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
new file mode 100644
index 0000000..5039972
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -0,0 +1,141 @@
+/**
+ * @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 '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-plugin-list_html';
+import {
+  ListViewMixin,
+  ListViewParams,
+} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {PluginInfo} from '../../../types/common';
+
+interface PluginInfoWithName extends PluginInfo {
+  name: string;
+}
+export interface GrPluginList {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-plugin-list')
+export class GrPluginList extends ListViewMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * URL params passed from the router.
+   */
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: ListViewParams;
+
+  /**
+   * Offset of currently visible query results.
+   */
+  @property({type: Number})
+  _offset = 0;
+
+  @property({type: String})
+  readonly _path = '/admin/plugins';
+
+  @property({type: Array})
+  _plugins?: PluginInfoWithName[];
+
+  /**
+   * Because  we request one more than the pluginsPerPage, _shownPlugins
+   * maybe one less than _plugins.
+   **/
+  @property({type: Array, computed: 'computeShownItems(_plugins)'})
+  _shownPlugins?: PluginInfoWithName[];
+
+  @property({type: Number})
+  _pluginsPerPage = 25;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _filter = '';
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: 'Plugins'},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _paramsChanged(params: ListViewParams) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+
+    return this._getPlugins(this._filter, this._pluginsPerPage, this._offset);
+  }
+
+  _getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
+    const errFn: ErrorCallback = 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 => {
+          return {...plugins[key], name: key};
+        });
+        this._loading = false;
+      });
+  }
+
+  _status(item: PluginInfo) {
+    return item.disabled === true ? 'Disabled' : 'Enabled';
+  }
+
+  _computePluginUrl(id: string) {
+    return this.getUrl('/', id);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-plugin-list': 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
deleted file mode 100644
index bd2bea3..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
new file mode 100644
index 0000000..d5318b5
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 6ca8afa4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
+++ /dev/null
@@ -1,209 +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">
-<meta charset="utf-8">
-<title>gr-plugin-list</title>
-
-<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>
-    <gr-plugin-list></gr-plugin-list>
-  </template>
-</test-fixture>
-
-<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,
-  };
-
-  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;
-};
-
-suite('gr-plugin-list tests', () => {
-  let element;
-  let plugins;
-  let sandbox;
-  let value;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    counter = 0;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('list with plugins', () => {
-    setup(done => {
-      plugins = _.times(26, pluginGenerator);
-
-      stub('gr-rest-api-interface', {
-        getPlugins(num, offset) {
-          return Promise.resolve(plugins);
-        },
-      });
-
-      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 = 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',
-          () => 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-plugin-list/gr-plugin-list_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
new file mode 100644
index 0000000..7303748
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
@@ -0,0 +1,189 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-plugin-list.js';
+import 'lodash/lodash.js';
+
+const basicFixture = fixtureFromElement('gr-plugin-list');
+
+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;
+};
+
+suite('gr-plugin-list tests', () => {
+  let element;
+  let plugins;
+
+  let value;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    counter = 0;
+  });
+
+  suite('list with plugins', () => {
+    setup(done => {
+      plugins = _.times(26, pluginGenerator);
+
+      stub('gr-rest-api-interface', {
+        getPlugins(num, offset) {
+          return Promise.resolve(plugins);
+        },
+      });
+
+      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 = 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 = element.root.querySelectorAll('.version');
+        assert.equal(versions[2].innerText, 'version-2');
+        assert.equal(versions[3].innerText, '--');
+        done();
+      });
+    });
+
+    test('api versions', done => {
+      flush(() => {
+        const apiVersions = 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 => {
+      sinon.stub(
+          element.$.restAPI,
+          'getPlugins')
+          .callsFake(() => 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);
+
+      flush();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      const response = {status: 404};
+      sinon.stub(element.$.restAPI, 'getPlugins').callsFake(
+          (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);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
new file mode 100644
index 0000000..40a1e0a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
@@ -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.
+ */
+
+/**
+ * @fileOverview This file contains interfaces shared between gr-repo-access
+ * and nested elements (gr-access-section, gr-permission)
+ */
+
+import {
+  AccessSectionInfo,
+  GroupInfo,
+  PermissionInfo,
+  PermissionRuleInfo,
+} from '../../../types/common';
+import {PermissionArrayItem} from '../../../utils/access-util';
+
+export type PrimitiveValue = string | boolean | number | undefined;
+
+export interface PropertyTreeNode {
+  [propName: string]: PropertyTreeNode | PrimitiveValue;
+  deleted?: boolean;
+  modified?: boolean;
+  added?: boolean;
+  updatedId?: string;
+}
+
+/**
+ * EditableLocalAccessSectionInfo is exactly the same as LocalAccessSectionInfo,
+ * but with additional properties: each nested object additionally implements
+ * interface PropertyTreeNode
+ */
+
+export type EditableLocalAccessSectionInfo = {
+  [ref: string]: EditableAccessSectionInfo;
+};
+
+export interface EditableAccessSectionInfo
+  extends AccessSectionInfo,
+    PropertyTreeNode {
+  permissions: EditableAccessPermissionsMap;
+}
+
+export type EditableAccessPermissionsMap = {
+  [permissionName: string]: EditablePermissionInfo;
+};
+
+export interface EditablePermissionInfo
+  extends PermissionInfo,
+    PropertyTreeNode {
+  rules: EditablePermissionInfoRules;
+}
+
+export type EditablePermissionInfoRules = {
+  [groupUUID: string]: EditablePermissionRuleInfo;
+};
+
+export interface EditablePermissionRuleInfo
+  extends PermissionRuleInfo,
+    PropertyTreeNode {}
+
+export type PermissionAccessSection = PermissionArrayItem<
+  EditableAccessSectionInfo
+>;
+
+export interface NewlyAddedGroupInfo {
+  name: string;
+}
+export type EditableProjectAccessGroups = {
+  [uuid: string]: GroupInfo | NewlyAddedGroupInfo;
+};
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
deleted file mode 100644
index 9aa81f8..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ /dev/null
@@ -1,524 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 Defs = {};
-
-const NOTHING_TO_SAVE = 'No changes to save.';
-
-const MAX_AUTOCOMPLETE_RESULTS = 50;
-
-/**
- * Fired when save is a no-op
- *
- * @event show-alert
- */
-
-/**
- * @typedef {{
- *    value: !Object,
- * }}
- */
-Defs.rule;
-
-/**
- * @typedef {{
- *    rules: !Object<string, Defs.rule>
- * }}
- */
-Defs.permission;
-
-/**
- * Can be an empty object or consist of permissions.
- *
- * @typedef {{
- *    permissions: !Object<string, Defs.permission>
- * }}
- */
-Defs.permissions;
-
-/**
- * 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;
-
-/**
- * @extends Polymer.Element
- */
-class GrRepoAccess extends mixinBehaviors( [
-  AccessBehavior,
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-repo-access'; }
-
-  static get properties() {
-    return {
-      repo: {
-        type: String,
-        observer: '_repoChanged',
-      },
-      // The current path
-      path: String,
-
-      _canUpload: {
-        type: Boolean,
-        value: false,
-      },
-      _inheritFromFilter: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getInheritFromSuggestions.bind(this);
-        },
-      },
-      _ownerOf: Array,
-      _capabilities: Object,
-      _groups: Object,
-      /** @type {?} */
-      _inheritsFrom: Object,
-      _labels: Object,
-      _local: Object,
-      _editing: {
-        type: Boolean,
-        value: false,
-        observer: '_handleEditingChanged',
-      },
-      _modified: {
-        type: Boolean,
-        value: false,
-      },
-      _sections: Array,
-      _weblinks: Array,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('access-modified',
-        () =>
-          this._handleAccessModified());
-  }
-
-  _handleAccessModified() {
-    this._modified = true;
-  }
-
-  /**
-   * @param {string} repo
-   * @return {!Promise}
-   */
-  _repoChanged(repo) {
-    this._loading = true;
-
-    if (!repo) { return Promise.resolve(); }
-
-    return this._reload(repo);
-  }
-
-  _reload(repo) {
-    const promises = [];
-
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-
-    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(); }
-
-          // 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(); }
-
-          return res;
-        }));
-
-    promises.push(this.$.restAPI.getRepo(repo, errFn)
-        .then(res => {
-          if (!res) { return Promise.resolve(); }
-
-          return res.labels;
-        }));
-
-    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();
-  }
-
-  _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;
-        } else {
-          curPos[item] = {};
-        }
-      }
-      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; }
-
-      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]);
-          }
-          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));
-      }
-    }
-  }
-
-  /**
-   * 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;
-          }
-        });
-  }
-
-  _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;
-          }
-        });
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
new file mode 100644
index 0000000..b96ec2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -0,0 +1,621 @@
+/**
+ * @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 '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-access-section/gr-access-section';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-access_html';
+import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {toSortedPermissionsArray} from '../../../utils/access-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+  RepoName,
+  ProjectInfo,
+  CapabilityInfoMap,
+  LabelNameToLabelTypeInfoMap,
+  ProjectAccessInput,
+  GitRef,
+  UrlEncodedRepoName,
+  ProjectAccessGroups,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrAccessSection} from '../gr-access-section/gr-access-section';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+  EditableLocalAccessSectionInfo,
+  PermissionAccessSection,
+  PropertyTreeNode,
+  PrimitiveValue,
+} from './gr-repo-access-interfaces';
+
+const NOTHING_TO_SAVE = 'No changes to save.';
+
+const MAX_AUTOCOMPLETE_RESULTS = 50;
+
+export interface GrRepoAccess {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+/**
+ * Fired when save is a no-op
+ *
+ * @event show-alert
+ */
+@customElement('gr-repo-access')
+export class GrRepoAccess extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, observer: '_repoChanged'})
+  repo?: RepoName;
+
+  @property({type: String})
+  path?: string;
+
+  @property({type: Boolean})
+  _canUpload?: boolean = false; // restAPI can return undefined
+
+  @property({type: String})
+  _inheritFromFilter?: RepoName;
+
+  @property({type: Object})
+  _query: AutocompleteQuery;
+
+  @property({type: Array})
+  _ownerOf?: GitRef[];
+
+  @property({type: Object})
+  _capabilities?: CapabilityInfoMap;
+
+  @property({type: Object})
+  _groups?: ProjectAccessGroups;
+
+  @property({type: Object})
+  _inheritsFrom?: ProjectInfo | null | {};
+
+  @property({type: Object})
+  _labels?: LabelNameToLabelTypeInfoMap;
+
+  @property({type: Object})
+  _local?: EditableLocalAccessSectionInfo;
+
+  @property({type: Boolean, observer: '_handleEditingChanged'})
+  _editing = false;
+
+  @property({type: Boolean})
+  _modified = false;
+
+  @property({type: Array})
+  _sections?: PermissionAccessSection[];
+
+  @property({type: Array})
+  _weblinks?: string[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  private _originalInheritsFrom?: ProjectInfo | null;
+
+  constructor() {
+    super();
+    this._query = () => this._getInheritFromSuggestions();
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-modified', () =>
+      this._handleAccessModified()
+    );
+  }
+
+  _handleAccessModified() {
+    this._modified = true;
+  }
+
+  _repoChanged(repo: RepoName) {
+    this._loading = true;
+
+    if (!repo) {
+      return Promise.resolve();
+    }
+
+    return this._reload(repo);
+  }
+
+  _reload(repo: RepoName) {
+    const errFn = (response?: Response | null) => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+
+    this._editing = false;
+
+    // Always reset sections when a project changes.
+    this._sections = [];
+    const sectionsPromises = this.$.restAPI
+      .getRepoAccessRights(repo, errFn)
+      .then(res => {
+        if (!res) {
+          return Promise.resolve(undefined);
+        }
+
+        // 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
+          ? {
+              ...res.inherits_from,
+            }
+          : null;
+        this._originalInheritsFrom = res.inherits_from
+          ? {
+              ...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
+          ? res.inherits_from.name
+          : ('' as RepoName);
+        // 'as EditableLocalAccessSectionInfo' is required because res.local
+        // type doesn't have index signature
+        this._local = res.local as EditableLocalAccessSectionInfo;
+        this._groups = res.groups;
+        this._weblinks = res.config_web_links || [];
+        this._canUpload = res.can_upload;
+        this._ownerOf = res.owner_of || [];
+        return toSortedPermissionsArray(this._local);
+      });
+
+    const capabilitiesPromises = this.$.restAPI
+      .getCapabilities(errFn)
+      .then(res => {
+        if (!res) {
+          return Promise.resolve(undefined);
+        }
+
+        return res;
+      });
+
+    const labelsPromises = this.$.restAPI.getRepo(repo, errFn).then(res => {
+      if (!res) {
+        return Promise.resolve(undefined);
+      }
+
+      return res.labels;
+    });
+
+    return Promise.all([
+      sectionsPromises,
+      capabilitiesPromises,
+      labelsPromises,
+    ]).then(([sections, capabilities, labels]) => {
+      this._capabilities = capabilities;
+      this._labels = labels;
+      this._sections = sections;
+      this._loading = false;
+    });
+  }
+
+  _handleUpdateInheritFrom(e: CustomEvent<{value: string}>) {
+    const parentProject: ProjectInfo = {
+      id: e.detail.value as UrlEncodedRepoName,
+      name: this._inheritFromFilter,
+    };
+    if (!this._inheritsFrom) {
+      this._inheritsFrom = parentProject;
+    } else {
+      // TODO(TS): replace with
+      // this._inheritsFrom = {...this._inheritsFrom, ...parentProject};
+      const projectInfo = this._inheritsFrom as ProjectInfo;
+      projectInfo.id = parentProject.id;
+      projectInfo.name = parentProject.name;
+    }
+    this._handleAccessModified();
+  }
+
+  _getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
+    return this.$.restAPI
+      .getRepos(this._inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
+      .then(response => {
+        const projects: AutocompleteSuggestion[] = [];
+        if (!response) {
+          return projects;
+        }
+        for (const item of response) {
+          projects.push({
+            name: item.name,
+            value: item.id,
+          });
+        }
+        return projects;
+      });
+  }
+
+  _computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  _handleEdit() {
+    this._editing = !this._editing;
+  }
+
+  _editOrCancel(editing: boolean) {
+    return editing ? 'Cancel' : 'Edit';
+  }
+
+  _computeWebLinkClass(weblinks?: string[]) {
+    return weblinks && weblinks.length ? 'show' : '';
+  }
+
+  _computeShowInherit(inheritsFrom?: RepoName) {
+    return inheritsFrom ? 'show' : '';
+  }
+
+  // TODO(TS): Unclear what is model here, provide a better explanation
+  _handleAddedSectionRemoved(e: CustomEvent & {model: {index: string}}) {
+    if (!this._sections) {
+      return;
+    }
+    const index = Number(e.model.index);
+    if (isNaN(index)) {
+      return;
+    }
+    this._sections = this._sections
+      .slice(0, index)
+      .concat(this._sections.slice(index + 1, this._sections.length));
+  }
+
+  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+    // 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 = {...this._originalInheritsFrom};
+      this._inheritFromFilter =
+        'name' in this._inheritsFrom ? this._inheritsFrom.name : undefined;
+    }
+    if (!this._local) {
+      return;
+    }
+    for (const key of Object.keys(this._local)) {
+      if (this._local[key].added) {
+        delete this._local[key];
+      }
+    }
+  }
+
+  _updateRemoveObj(addRemoveObj: {remove: PropertyTreeNode}, path: string[]) {
+    let curPos: PropertyTreeNode = addRemoveObj.remove;
+    for (const item of path) {
+      if (!curPos[item]) {
+        if (item === path[path.length - 1]) {
+          if (path[path.length - 2] === 'permissions') {
+            curPos[item] = {rules: {}};
+          } else if (path.length === 1) {
+            curPos[item] = {permissions: {}};
+          } else {
+            curPos[item] = {};
+          }
+        } else {
+          curPos[item] = {};
+        }
+      }
+      // The last item can be a PrimitiveValue, but we don't use it
+      // All intermediate items are PropertyTreeNode
+      // TODO(TS): rewrite this loop and process the last item explicitly
+      curPos = curPos[item] as PropertyTreeNode;
+    }
+    return addRemoveObj;
+  }
+
+  _updateAddObj(
+    addRemoveObj: {add: PropertyTreeNode},
+    path: string[],
+    value: PropertyTreeNode | PrimitiveValue
+  ) {
+    let curPos: PropertyTreeNode = addRemoveObj.add;
+    for (const item of path) {
+      if (!curPos[item]) {
+        if (item === path[path.length - 1]) {
+          curPos[item] = value;
+        } else {
+          curPos[item] = {};
+        }
+      }
+      // The last item can be a PrimitiveValue, but we don't use it
+      // All intermediate items are PropertyTreeNode
+      // TODO(TS): rewrite this loop and process the last item explicitly
+      curPos = curPos[item] as PropertyTreeNode;
+    }
+    return addRemoveObj;
+  }
+
+  /**
+   * Used to recursively remove any objects with a 'deleted' bit.
+   */
+  _recursivelyRemoveDeleted(obj: PropertyTreeNode) {
+    for (const k in obj) {
+      if (!hasOwnProperty(obj, k)) {
+        continue;
+      }
+      const node = obj[k];
+      if (typeof node === 'object') {
+        if (node.deleted) {
+          delete obj[k];
+          return;
+        }
+        this._recursivelyRemoveDeleted(node);
+      }
+    }
+  }
+
+  _recursivelyUpdateAddRemoveObj(
+    obj: PropertyTreeNode,
+    addRemoveObj: {
+      add: PropertyTreeNode;
+      remove: PropertyTreeNode;
+    },
+    path: string[] = []
+  ) {
+    for (const k in obj) {
+      if (!hasOwnProperty(obj, k)) {
+        continue;
+      }
+      const node = obj[k];
+      if (typeof node === 'object') {
+        const updatedId = node.updatedId;
+        const ref = updatedId ? updatedId : k;
+        if (node.deleted) {
+          this._updateRemoveObj(addRemoveObj, path.concat(k));
+          continue;
+        } else if (node.modified) {
+          this._updateRemoveObj(addRemoveObj, path.concat(k));
+          this._updateAddObj(addRemoveObj, path.concat(ref), node);
+          /* 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] as PropertyTreeNode
+            );
+          }
+          continue;
+        } else if (node.added) {
+          this._updateAddObj(addRemoveObj, path.concat(ref), node);
+          /**
+           * 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] as PropertyTreeNode
+          );
+          continue;
+        }
+        this._recursivelyUpdateAddRemoveObj(node, addRemoveObj, path.concat(k));
+      }
+    }
+  }
+
+  /**
+   * Returns an object formatted for saving or submitting access changes for
+   * review
+   */
+  _computeAddAndRemove() {
+    const addRemoveObj: {
+      add: PropertyTreeNode;
+      remove: PropertyTreeNode;
+      parent?: string | null;
+    } = {
+      add: {},
+      remove: {},
+    };
+
+    const originalInheritsFromId = this._originalInheritsFrom
+      ? singleDecodeURL(this._originalInheritsFrom.id)
+      : null;
+    // TODO(TS): this._inheritsFrom as ProjectInfo might be a mistake.
+    // _inheritsFrom can be {}
+    const inheritsFromId = this._inheritsFrom
+      ? singleDecodeURL((this._inheritsFrom as ProjectInfo).id)
+      : null;
+
+    const inheritFromChanged =
+      // Inherit from changed
+      (originalInheritsFromId && originalInheritsFromId !== inheritsFromId) ||
+      // Inherit from added (did not have one initially);
+      (!originalInheritsFromId && inheritsFromId);
+
+    if (!this._local) {
+      return addRemoveObj;
+    }
+
+    this._recursivelyUpdateAddRemoveObj(
+      (this._local as unknown) as PropertyTreeNode,
+      addRemoveObj
+    );
+
+    if (inheritFromChanged) {
+      addRemoveObj.parent = inheritsFromId;
+    }
+    return addRemoveObj;
+  }
+
+  _handleCreateSection() {
+    if (!this._local) {
+      return;
+    }
+    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();
+    // Template already instantiated at this point
+    (this.root!.querySelector(
+      'gr-access-section:last-of-type'
+    ) as GrAccessSection).editReference();
+  }
+
+  _getObjforSave(): ProjectAccessInput | undefined {
+    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: ProjectAccessInput = ({
+      add: addRemoveObj.add,
+      remove: addRemoveObj.remove,
+    } as unknown) as ProjectAccessInput;
+    if (addRemoveObj.parent) {
+      obj.parent = addRemoveObj.parent;
+    }
+    return obj;
+  }
+
+  _handleSave(e: Event) {
+    const obj = this._getObjforSave();
+    if (!obj) {
+      return;
+    }
+    const button = e && (e.target as GrButton);
+    if (button) {
+      button.loading = true;
+    }
+    const repo = this.repo;
+    if (!repo) {
+      return Promise.resolve();
+    }
+    return this.$.restAPI
+      .setRepoAccessRights(repo, obj)
+      .then(() => {
+        this._reload(repo);
+      })
+      .finally(() => {
+        this._modified = false;
+        if (button) {
+          button.loading = false;
+        }
+      });
+  }
+
+  _handleSaveForReview(e: Event) {
+    const obj = this._getObjforSave();
+    if (!obj) {
+      return;
+    }
+    const button = e && (e.target as GrButton);
+    if (button) {
+      button.loading = true;
+    }
+    if (!this.repo) {
+      return;
+    }
+    return this.$.restAPI
+      .setRepoAccessRightsForReview(this.repo, obj)
+      .then(change => {
+        GerritNav.navigateToChange(change);
+      })
+      .finally(() => {
+        this._modified = false;
+        if (button) {
+          button.loading = false;
+        }
+      });
+  }
+
+  _computeSaveReviewBtnClass(canUpload?: boolean) {
+    return !canUpload ? 'invisible' : '';
+  }
+
+  _computeSaveBtnClass(ownerOf?: GitRef[]) {
+    return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
+  }
+
+  _computeMainClass(
+    ownerOf: GitRef[] | undefined,
+    canUpload: boolean,
+    editing: boolean
+  ) {
+    const classList = [];
+    if ((ownerOf && ownerOf.length > 0) || canUpload) {
+      classList.push('admin');
+    }
+    if (editing) {
+      classList.push('editing');
+    }
+    return classList.join(' ');
+  }
+
+  _computeParentHref(repoName: RepoName) {
+    return getBaseUrl() + `/admin/repos/${encodeURL(repoName, true)},access`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-access': 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
deleted file mode 100644
index cf77233..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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]]"
-          repo="[[repo]]"
-          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_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
new file mode 100644
index 0000000..2c21329
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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);
+    }
+    gr-access-section {
+      margin-top: var(--spacing-l);
+    }
+    .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$="heading-3 [[_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>
+      <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]]"
+          repo="[[repo]]"
+          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>
+        <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
+        >
+      </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
deleted file mode 100644
index 7d66cb0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
+++ /dev/null
@@ -1,1254 +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">
-<meta charset="utf-8">
-<title>gr-repo-access</title>
-
-<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>
-    <gr-repo-access></gr-repo-access>
-  </template>
-</test-fixture>
-
-<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';
-
-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'},
-            },
-          },
-        },
-      },
-    },
-    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',
-              },
-            },
-          },
-        },
-      },
-    },
-  };
-  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',
-      },
-    };
-    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();
-    });
-
-    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,
-          'none');
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#editInheritFromInput'))
-          .display, 'none');
-
-      MockInteractions.tap(element.$.editBtn);
-      flushAsynchronousOperations();
-
-      // 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.shadowRoot
-          .querySelector('#editInheritFromInput'))
-          .display, 'none');
-
-      // 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);
-      }
-    };
-
-    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.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,
-        },
-      };
-
-      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 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: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      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', 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 saveForReviewStub = sandbox.stub(element.$.restAPI,
-          'setRepoAccessRightsForReview')
-          .returns(new Promise(r => resolver = r));
-
-      element.repo = 'test-repo';
-      sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-      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-access/gr-repo-access_test.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
new file mode 100644
index 0000000..d3204e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
@@ -0,0 +1,1235 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.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';
+import {toSortedPermissionsArray} from '../../../utils/access-util.js';
+
+const basicFixture = fixtureFromElement('gr-repo-access');
+
+suite('gr-repo-access tests', () => {
+  let element;
+
+  let repoStub;
+
+  const accessRes = {
+    local: {
+      'refs/*': {
+        permissions: {
+          owner: {
+            rules: {
+              234: {action: 'ALLOW'},
+              123: {action: 'DENY'},
+            },
+          },
+          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',
+              },
+            },
+          },
+        },
+      },
+    },
+  };
+  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(() => {
+    element = basicFixture.instantiate();
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(null); },
+    });
+    repoStub = sinon.stub(element.$.restAPI, 'getRepo').returns(
+        Promise.resolve(repoRes));
+    element._loading = false;
+    element._ownerOf = [];
+    element._canUpload = false;
+  });
+
+  test('_repoChanged called when repo name changes', () => {
+    sinon.stub(element, '_repoChanged');
+    element.repo = 'New Repo';
+    assert.isTrue(element._repoChanged.called);
+  });
+
+  test('_repoChanged', done => {
+    const accessStub = sinon.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 = sinon.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,
+          toSortedPermissionsArray(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,
+              toSortedPermissionsArray(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',
+      },
+    };
+    const accessStub = sinon.stub(element.$.restAPI, 'getRepoAccessRights')
+        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = sinon.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 = [];
+    sinon.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.
+    flush();
+    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};
+
+    sinon.stub(
+        element.$.restAPI, 'getRepoAccessRights')
+        .callsFake((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,
+          'none');
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      flush();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#editInheritFromInput'))
+          .display, 'none');
+
+      MockInteractions.tap(element.$.editBtn);
+      flush();
+
+      // 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.shadowRoot
+          .querySelector('#editInheritFromInput'))
+          .display, 'none');
+
+      // 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);
+      }
+    };
+
+    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 = toSortedPermissionsArray(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));
+      flush();
+    });
+
+    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,
+              }));
+      flush();
+      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', () => {
+      sinon.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',
+      };
+      flush();
+      element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
+          new CustomEvent('commit', {
+            detail: {},
+            composed: true, bubbles: true,
+          }));
+      sinon.spy(element, '_handleAccessModified');
+      element.dispatchEvent(
+          new CustomEvent('access-modified', {
+            detail: {},
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleAccessModified.called);
+    });
+
+    test('_handleSaveForReview', () => {
+      const saveStub =
+          sinon.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
+      sinon.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,
+        },
+      };
+
+      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 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: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      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: 'Maintainers'}});
+
+      flush();
+      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();
+      flush();
+      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: '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();
+      flush();
+      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: 'Maintainers'}});
+
+      flush();
+      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: '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();
+      flush();
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: '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();
+      flush();
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: '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: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+      sinon.stub(GerritNav, 'navigateToChange');
+      let resolver;
+      const saveStub = sinon.stub(element.$.restAPI,
+          'setRepoAccessRights')
+          .returns(new Promise(r => resolver = r));
+
+      element.repo = 'test-repo';
+      sinon.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', done => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+      sinon.stub(GerritNav, 'navigateToChange');
+      let resolver;
+      const saveForReviewStub = sinon.stub(element.$.restAPI,
+          'setRepoAccessRightsForReview')
+          .returns(new Promise(r => resolver = r));
+
+      element.repo = 'test-repo';
+      sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
+
+      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();
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index 53b4989..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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
-   */
-
-  _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
deleted file mode 100644
index cf934b0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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
deleted file mode 100644
index a73f071..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
+++ /dev/null
@@ -1,53 +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">
-<meta charset="utf-8">
-<title>gr-repo-command</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-repo-command></gr-repo-command>
-  </template>
-</test-fixture>
-
-<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(
-        dom(element.root).querySelector('gr-button'));
-  });
-});
-</script>
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
deleted file mode 100644
index 1f1dc5b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ /dev/null
@@ -1,145 +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.
- */
-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 '../../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 GC_MESSAGE = 'Garbage collection completed successfully.';
-
-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';
-
-/**
- * @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: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {?} */
-      _repoConfig: Object,
-      _canCreate: Boolean,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadRepo();
-
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Repo Commands'},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _loadRepo() {
-    if (!this.repo) { return Promise.resolve(); }
-
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-
-    return this.$.restAPI.getProjectConfig(this.repo, errFn)
-        .then(config => {
-          if (!config) { return Promise.resolve(); }
-
-          this._repoConfig = config;
-          this._loading = false;
-        });
-  }
-
-  _computeLoadingClass(loading) {
-    return loading ? 'loading' : '';
-  }
-
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _handleRunningGC() {
-    return this.$.restAPI.runRepoGC(this.repo).then(response => {
-      if (response.status === 200) {
-        this.dispatchEvent(new CustomEvent(
-            'show-alert',
-            {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
-      }
-    });
-  }
-
-  _createNewChange() {
-    this.$.createChangeOverlay.open();
-  }
-
-  _handleCreateChange() {
-    this.$.createNewChangeModal.handleCreateChange();
-    this._handleCloseCreateChange();
-  }
-
-  _handleCloseCreateChange() {
-    this.$.createChangeOverlay.close();
-  }
-
-  _handleEditRepoConfig() {
-    return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
-        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));
-    });
-  }
-}
-
-customElements.define(GrRepoCommands.is, GrRepoCommands);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
new file mode 100644
index 0000000..a74f4bb
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -0,0 +1,218 @@
+/**
+ * @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-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-change-dialog/gr-create-change-dialog';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-commands_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  BranchName,
+  ConfigInfo,
+  PatchSetNum,
+  RepoName,
+} from '../../../types/common';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
+
+const GC_MESSAGE = 'Garbage collection completed successfully.';
+const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
+const CONFIG_PATH = 'project.config';
+const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
+const INITIAL_PATCHSET = 1 as PatchSetNum;
+const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
+const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
+
+export interface GrRepoCommands {
+  $: {
+    restAPI: RestApiService & Element;
+    createChangeOverlay: GrOverlay;
+    createNewChangeModal: GrCreateChangeDialog;
+  };
+}
+
+@customElement('gr-repo-commands')
+export class GrRepoCommands extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  // This is a required property. Without `repo` being set the component is not
+  // useful. Thus using !.
+  @property({type: String})
+  repo!: RepoName;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Object})
+  _repoConfig?: ConfigInfo;
+
+  @property({type: Boolean})
+  _canCreate = false;
+
+  @property({type: Boolean})
+  _creatingChange = false;
+
+  @property({type: Boolean})
+  _editingConfig = false;
+
+  @property({type: Boolean})
+  _runningGC = false;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadRepo();
+
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: 'Repo Commands'},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _loadRepo() {
+    const errFn: ErrorCallback = response => {
+      // Do not process the error, if the component is not attached to the DOM
+      // anymore, which at least in tests can happen.
+      if (!this.isConnected) return;
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+
+    this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+      if (!config) return;
+      // Do not process the response, if the component is not attached to the
+      // DOM anymore, which at least in tests can happen.
+      if (!this.isConnected) return;
+      this._repoConfig = config;
+      this._loading = false;
+    });
+  }
+
+  _computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  _isLoading() {
+    return this._loading;
+  }
+
+  _handleRunningGC() {
+    this._runningGC = true;
+    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,
+            })
+          );
+        }
+      })
+      .finally(() => {
+        this._runningGC = false;
+      });
+  }
+
+  _createNewChange() {
+    this.$.createChangeOverlay.open();
+  }
+
+  _handleCreateChange() {
+    this._creatingChange = true;
+    this.$.createNewChangeModal.handleCreateChange().finally(() => {
+      this._creatingChange = false;
+    });
+    this._handleCloseCreateChange();
+  }
+
+  _handleCloseCreateChange() {
+    this.$.createChangeOverlay.close();
+  }
+
+  /**
+   * Returns a Promise for testing.
+   */
+  _handleEditRepoConfig() {
+    this._editingConfig = true;
+    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)
+        );
+      })
+      .finally(() => {
+        this._editingConfig = false;
+      });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-commands': 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
deleted file mode 100644
index b27c36b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
new file mode 100644
index 0000000..3880e4a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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">
+    #form gr-button {
+      margin-bottom: var(--spacing-xxl);
+    }
+  </style>
+  <main class="gr-form-styles read-only">
+    <h1 id="Title" class="heading-1">Repository Commands</h1>
+    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      Loading...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <h2 id="options" class="heading-2">Command</h2>
+      <div id="form">
+        <h3>Create change</h3>
+        <gr-button loading="[[_creatingChange]]" on-click="_createNewChange">
+          Create change
+        </gr-button>
+        <h3>Edit repo config</h3>
+        <gr-button
+          id="editRepoConfig"
+          loading="[[_editingConfig]]"
+          on-click="_handleEditRepoConfig"
+        >
+          Edit repo config
+        </gr-button>
+        <h3 hidden="[[!_repoConfig.actions.gc.enabled]]">
+          [[_repoConfig.actions.gc.label]]
+        </h3>
+        <gr-button
+          hidden="[[!_repoConfig.actions.gc.enabled]]"
+          title="[[_repoConfig.actions.gc.title]]"
+          loading="[[_runningGC]]"
+          on-click="_handleRunningGC"
+        >
+          [[_repoConfig.actions.gc.label]]
+        </gr-button>
+        <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
deleted file mode 100644
index db2bfcf..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
+++ /dev/null
@@ -1,151 +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">
-<meta charset="utf-8">
-<title>gr-repo-commands</title>
-
-<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>
-    <gr-repo-commands></gr-repo-commands>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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]);
-      });
-    });
-
-    test('unsuccessful creation of change', () => {
-      createChangeStub.returns(Promise.resolve(null));
-      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,
-            '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-commands/gr-repo-commands_test.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
new file mode 100644
index 0000000..efe4012
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
@@ -0,0 +1,135 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-repo-commands.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-repo-commands');
+
+suite('gr-repo-commands tests', () => {
+  let element;
+
+  let repoStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    // Note that this probably does not achieve what it is supposed to, because
+    // getProjectConfig() is called as soon as the element is attached, so
+    // stubbing it here has not effect anymore.
+    repoStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
+        .returns(Promise.resolve({}));
+  });
+
+  suite('create new change dialog', () => {
+    test('_createNewChange opens modal', () => {
+      const openStub = sinon.stub(element.$.createChangeOverlay, 'open');
+      element._createNewChange();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateChange called when confirm fired', () => {
+      sinon.stub(element, '_handleCreateChange');
+      element.$.createChangeDialog.dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCreateChange.called);
+    });
+
+    test('_handleCloseCreateChange called when cancel fired', () => {
+      sinon.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(() => {
+      createChangeStub = sinon.stub(element.$.restAPI, 'createChange');
+      urlStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
+      sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      handleSpy = sinon.spy(element, '_handleEditRepoConfig');
+      alertStub = sinon.stub();
+      element.repo = 'test';
+      element.addEventListener('show-alert', alertStub);
+    });
+
+    test('successful creation of change', () => {
+      const change = {_number: '1'};
+      createChangeStub.returns(Promise.resolve(change));
+      MockInteractions.tap(element.$.editRepoConfig);
+      assert.isTrue(element.$.editRepoConfig.loading);
+      return handleSpy.lastCall.returnValue.then(() => {
+        flush();
+
+        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]);
+        assert.isFalse(element.$.editRepoConfig.loading);
+      });
+    });
+
+    test('unsuccessful creation of change', () => {
+      createChangeStub.returns(Promise.resolve(null));
+      MockInteractions.tap(element.$.editRepoConfig);
+      assert.isTrue(element.$.editRepoConfig.loading);
+      return handleSpy.lastCall.returnValue.then(() => {
+        flush();
+
+        assert.isTrue(alertStub.called);
+        assert.equal(alertStub.lastCall.args[0].detail.message,
+            'Failed to create change.');
+        assert.isFalse(urlStub.called);
+        assert.isFalse(element.$.editRepoConfig.loading);
+      });
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      repoStub.restore();
+
+      element.repo = 'test';
+
+      const response = {status: 404};
+      sinon.stub(
+          element.$.restAPI, 'getProjectConfig')
+          .callsFake((repo, errFn) => {
+            errFn(response);
+          });
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element._loadRepo();
+    });
+  });
+});
+
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
deleted file mode 100644
index 072fc721..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ /dev/null
@@ -1,110 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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',
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _dashboards: Array,
-    };
-  }
-
-  _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,
-      }));
-    };
-
-    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;
-      flush();
-    });
-  }
-
-  _getUrl(project, id) {
-    if (!project || !id) { return ''; }
-
-    return GerritNav.getUrlForRepoDashboard(project, id);
-  }
-
-  _computeLoadingClass(loading) {
-    return loading ? 'loading' : '';
-  }
-
-  _computeInheritedFrom(project, definingProject) {
-    return project === definingProject ? '' : definingProject;
-  }
-
-  _computeIsDefault(isDefault) {
-    return isDefault ? '✓' : '';
-  }
-}
-
-customElements.define(GrRepoDashboards.is, GrRepoDashboards);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
new file mode 100644
index 0000000..d9d8560
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -0,0 +1,134 @@
+/**
+ * @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.
+ */
+
+import '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-dashboards_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
+import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+interface DashboardRef {
+  section: string;
+  dashboards: DashboardInfo[];
+}
+
+export interface GrRepoDashboards {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-repo-dashboards')
+export class GrRepoDashboards extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, observer: '_repoChanged'})
+  repo?: RepoName;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Array})
+  _dashboards?: DashboardRef[];
+
+  _repoChanged(repo?: RepoName) {
+    this._loading = true;
+    if (!repo) {
+      return Promise.resolve();
+    }
+
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+
+    return this.$.restAPI
+      .getRepoDashboards(repo, errFn)
+      .then((res?: DashboardInfo[]) => {
+        if (!res) {
+          return;
+        }
+
+        // Group by ref and sort by id.
+        const dashboards = res.concat
+          .apply([], res)
+          .sort((a, b) => (a.id < b.id ? -1 : 1));
+        const dashboardsByRef: Record<string, DashboardInfo[]> = {};
+        dashboards.forEach(d => {
+          if (!dashboardsByRef[d.ref]) {
+            dashboardsByRef[d.ref] = [];
+          }
+          dashboardsByRef[d.ref].push(d);
+        });
+
+        const dashboardBuilder: DashboardRef[] = [];
+        Object.keys(dashboardsByRef)
+          .sort()
+          .forEach(ref => {
+            dashboardBuilder.push({
+              section: ref,
+              dashboards: dashboardsByRef[ref],
+            });
+          });
+
+        this._dashboards = dashboardBuilder;
+        this._loading = false;
+        flush();
+      });
+  }
+
+  _getUrl(project: RepoName, id: DashboardId) {
+    if (!project || !id) {
+      return '';
+    }
+
+    return GerritNav.getUrlForRepoDashboard(project, id);
+  }
+
+  _computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  _computeInheritedFrom(project: RepoName, definingProject: RepoName) {
+    return project === definingProject ? '' : definingProject;
+  }
+
+  _computeIsDefault(isDefault: boolean) {
+    return isDefault ? '✓' : '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-dashboards': 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
deleted file mode 100644
index 8ce69df..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
new file mode 100644
index 0000000..7cdd10e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
@@ -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';
+
+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
deleted file mode 100644
index dc12eff..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
+++ /dev/null
@@ -1,161 +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">
-<meta charset="utf-8">
-<title>gr-repo-dashboards</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-repo-dashboards></gr-repo-dashboards>
-  </template>
-</test-fixture>
-
-<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.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,
-          'none');
-      assert.equal(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');
-
-        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(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-dashboards/gr-repo-dashboards_test.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
new file mode 100644
index 0000000..b4d3575
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
@@ -0,0 +1,141 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-repo-dashboards.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-repo-dashboards');
+
+suite('gr-repo-dashboards tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('dashboard table', () => {
+    setup(() => {
+      sinon.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,
+          'none');
+      assert.equal(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');
+
+        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', () => {
+      sinon.stub(GerritNav, 'getUrlForRepoDashboard').callsFake(
+          () => '/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};
+      sinon.stub(
+          element.$.restAPI, 'getRepoDashboards')
+          .callsFake((repo, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element.repo = 'test';
+    });
+  });
+});
+
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
deleted file mode 100644
index e8b3d9a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ /dev/null
@@ -1,314 +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.
- */
-
-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 DETAIL_TYPES = {
-  BRANCHES: 'branches',
-  TAGS: 'tags',
-};
-
-const PGP_START = '-----BEGIN PGP SIGNATURE-----';
-
-/**
- * @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',
-      },
-      /**
-       * The kind of detail we are displaying, possibilities are determined by
-       * the const DETAIL_TYPES.
-       */
-      detailType: String,
-
-      _editing: {
-        type: Boolean,
-        value: false,
-      },
-      _isOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _repo: Object,
-      _items: Array,
-      /**
-       * Because  we request one more than the projectsPerPage, _shownProjects
-       * maybe one less than _projects.
-       */
-      _shownItems: {
-        type: Array,
-        computed: 'computeShownItems(_items)',
-      },
-      _itemsPerPage: {
-        type: Number,
-        value: 25,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: String,
-      _refName: String,
-      _hasNewItemName: Boolean,
-      _isEditing: Boolean,
-      _revisedRef: String,
-    };
-  }
-
-  _determineIfOwner(repo) {
-    return this.$.restAPI.getRepoAccess(repo)
-        .then(access =>
-          this._isOwner = access && !!access[repo].is_owner);
-  }
-
-  _paramsChanged(params) {
-    if (!params || !params.repo) { return; }
-
-    this._repo = params.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;
-      });
-    } 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);
-      }
-    });
-  }
-
-  _computeItemName(detailType) {
-    if (detailType === DETAIL_TYPES.BRANCHES) {
-      return 'Branch';
-    } else if (detailType === DETAIL_TYPES.TAGS) {
-      return 'Tag';
-    }
-  }
-
-  _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);
-            }
-          });
-    }
-  }
-
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
-  }
-
-  _handleDeleteItem(e) {
-    const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
-    if (!name) { return; }
-    this._refName = name;
-    this.$.overlay.open();
-  }
-
-  _computeHideDeleteClass(owner, canDelete) {
-    if (canDelete || owner) {
-      return 'show';
-    }
-
-    return '';
-  }
-
-  _handleCreateItem() {
-    this.$.createNewModal.handleCreateItem();
-    this._handleCloseCreate();
-  }
-
-  _handleCloseCreate() {
-    this.$.createOverlay.close();
-  }
-
-  _handleCreateClicked() {
-    this.$.createOverlay.open();
-  }
-
-  _hideIfBranch(type) {
-    if (type === DETAIL_TYPES.BRANCHES) {
-      return 'hideItem';
-    }
-
-    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.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
new file mode 100644
index 0000000..2fce6e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -0,0 +1,397 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-detail-list_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {encodeURL} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import {
+  RepoName,
+  ProjectInfo,
+  BranchInfo,
+  GitRef,
+  TagInfo,
+  GitPersonInfo,
+} from '../../../types/common';
+import {AppElementRepoParams} from '../../gr-app-types';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+
+const PGP_START = '-----BEGIN PGP SIGNATURE-----';
+
+export interface GrRepoDetailList {
+  $: {
+    restAPI: RestApiService & Element;
+    overlay: GrOverlay;
+    createOverlay: GrOverlay;
+    createNewModal: GrCreatePointerDialog;
+  };
+}
+@customElement('gr-repo-detail-list')
+export class GrRepoDetailList extends ListViewMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementRepoParams;
+
+  @property({type: String})
+  detailType?: RepoDetailView;
+
+  @property({type: Boolean})
+  _editing = false;
+
+  @property({type: Boolean})
+  _isOwner = false;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Number})
+  _offset?: number;
+
+  @property({type: String})
+  _repo?: RepoName;
+
+  @property({type: Array})
+  _items?: BranchInfo[] | TagInfo[];
+
+  @property({type: Array, computed: 'computeShownItems(_items)'})
+  _shownItems?: BranchInfo[] | TagInfo[];
+
+  @property({type: Number})
+  _itemsPerPage = 25;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _filter?: string;
+
+  @property({type: String})
+  _refName?: GitRef;
+
+  @property({type: Boolean})
+  _hasNewItemName?: boolean;
+
+  @property({type: Boolean})
+  _isEditing?: boolean;
+
+  @property({type: String})
+  _revisedRef?: GitRef;
+
+  _determineIfOwner(repo: RepoName) {
+    return this.$.restAPI
+      .getRepoAccess(repo)
+      .then(access => (this._isOwner = !!access && !!access[repo].is_owner));
+  }
+
+  _paramsChanged(params?: AppElementRepoParams) {
+    if (!params?.repo) {
+      return Promise.reject(new Error('undefined repo'));
+    }
+
+    // paramsChanged is called before gr-admin-view can set _showRepoDetailList
+    // to false and polymer removes this component, hence check for params
+    if (
+      !(
+        params?.detail === RepoDetailView.BRANCHES ||
+        params?.detail === RepoDetailView.TAGS
+      )
+    ) {
+      return;
+    }
+
+    this._repo = params.repo;
+
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn && this._repo) {
+        this._determineIfOwner(this._repo);
+      }
+    });
+
+    this.detailType = params.detail;
+
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+    if (!this.detailType)
+      return Promise.reject(new Error('undefined detailType'));
+
+    return this._getItems(
+      this._filter,
+      this._repo,
+      this._itemsPerPage,
+      this._offset,
+      this.detailType
+    );
+  }
+
+  // TODO(TS) Move this to object for easier read, understand.
+  _getItems(
+    filter: string | undefined,
+    repo: RepoName | undefined,
+    itemsPerPage: number,
+    offset: number | undefined,
+    detailType: string
+  ) {
+    if (filter === undefined || !repo || offset === undefined) {
+      return Promise.reject(new Error('filter or repo or offset undefined'));
+    }
+    this._loading = true;
+    this._items = [];
+    flush();
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+    if (detailType === RepoDetailView.BRANCHES) {
+      return this.$.restAPI
+        .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
+        .then(items => {
+          if (!items) {
+            return;
+          }
+          this._items = items;
+          this._loading = false;
+        });
+    } else if (detailType === RepoDetailView.TAGS) {
+      return this.$.restAPI
+        .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
+        .then(items => {
+          if (!items) {
+            return;
+          }
+          this._items = items;
+          this._loading = false;
+        });
+    }
+    return Promise.reject(new Error('unknown detail type'));
+  }
+
+  _getPath(repo: RepoName) {
+    return `/admin/repos/${encodeURL(repo, false)},${this.detailType}`;
+  }
+
+  _computeWeblink(repo: ProjectInfo) {
+    if (!repo.web_links) {
+      return '';
+    }
+    const webLinks = repo.web_links;
+    return webLinks.length ? webLinks : null;
+  }
+
+  _computeFirstWebLink(repo: ProjectInfo) {
+    const webLinks = this._computeWeblink(repo);
+    return webLinks ? webLinks[0].url : null;
+  }
+
+  _computeMessage(message?: string) {
+    if (!message) {
+      return;
+    }
+    // Strip PGP info.
+    return message.split(PGP_START)[0];
+  }
+
+  _stripRefs(item: GitRef, detailType?: string) {
+    if (detailType === RepoDetailView.BRANCHES) {
+      return item.replace('refs/heads/', '');
+    } else if (detailType === RepoDetailView.TAGS) {
+      return item.replace('refs/tags/', '');
+    }
+    throw new Error('unknown detailType');
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _computeEditingClass(isEditing: boolean) {
+    return isEditing ? 'editing' : '';
+  }
+
+  _computeCanEditClass(ref: GitRef, detailType: string, isOwner: boolean) {
+    return isOwner && this._stripRefs(ref, detailType) === 'HEAD'
+      ? 'canEdit'
+      : '';
+  }
+
+  _handleEditRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
+    this._revisedRef = (e.model.get('item.revision') as unknown) as GitRef;
+    this._isEditing = true;
+  }
+
+  _handleCancelRevision() {
+    this._isEditing = false;
+  }
+
+  _handleSaveRevision(e: PolymerDomRepeatEvent<GitRef>) {
+    if (this._revisedRef && this._repo)
+      this._setRepoHead(this._repo, this._revisedRef, e);
+  }
+
+  _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
+    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!
+        );
+      }
+    });
+  }
+
+  _computeItemName(detailType: string) {
+    if (detailType === RepoDetailView.BRANCHES) {
+      return 'Branch';
+    } else if (detailType === RepoDetailView.TAGS) {
+      return 'Tag';
+    }
+    throw new Error('unknown detailType');
+  }
+
+  _handleDeleteItemConfirm() {
+    this.$.overlay.close();
+    if (!this._repo || !this._refName) {
+      return Promise.reject(new Error('undefined repo or refName'));
+    }
+    if (this.detailType === RepoDetailView.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 === RepoDetailView.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!
+            );
+          }
+        });
+    }
+    return Promise.reject(new Error('unknown detail type'));
+  }
+
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteItem(e: PolymerDomRepeatEvent<GitRef>) {
+    const name = this._stripRefs(
+      e.model.get('item.ref'),
+      this.detailType
+    ) as GitRef;
+    if (!name) {
+      return;
+    }
+    this._refName = name;
+    this.$.overlay.open();
+  }
+
+  _computeHideDeleteClass(owner: boolean, canDelete: boolean) {
+    if (canDelete || owner) {
+      return 'show';
+    }
+
+    return '';
+  }
+
+  _handleCreateItem() {
+    this.$.createNewModal.handleCreateItem();
+    this._handleCloseCreate();
+  }
+
+  _handleCloseCreate() {
+    this.$.createOverlay.close();
+  }
+
+  _handleCreateClicked() {
+    this.$.createOverlay.open();
+  }
+
+  _hideIfBranch(type: string) {
+    if (type === RepoDetailView.BRANCHES) {
+      return 'hideItem';
+    }
+
+    return '';
+  }
+
+  _computeHideTagger(tagger: GitPersonInfo) {
+    return tagger ? '' : 'hide';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-detail-list': 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
deleted file mode 100644
index 21971e7..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js
+++ /dev/null
@@ -1,222 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
new file mode 100644
index 0000000..196797f
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
@@ -0,0 +1,224 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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">
+              <a href$="[[_computeFirstWebLink(item)]]">
+                [[_stripRefs(item.ref, detailType)]]
+              </a>
+            </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
deleted file mode 100644
index 9d7bba4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
+++ /dev/null
@@ -1,576 +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">
-<meta charset="utf-8">
-<title>gr-repo-detail-list</title>
-
-<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>
-    <gr-repo-detail-list></gr-repo-detail-list>
-  </template>
-</test-fixture>
-
-<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;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.detailType = 'branches';
-      counter = 0;
-      sandbox.stub(page, 'show');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('list of repo branches', () => {
-      setup(done => {
-        branches = [{
-          ref: 'HEAD',
-          revision: 'master',
-        }].concat(_.times(25, branchGenerator));
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, project, offset) {
-            return Promise.resolve(branches);
-          },
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'branches',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('test for branch in the list', done => {
-        flush(() => {
-          assert.equal(element._items[2].ref, 'refs/heads/test2');
-          done();
-        });
-      });
-
-      test('test for web links in the branches list', done => {
-        flush(() => {
-          assert.equal(element._items[2].web_links[0].url,
-              'https://git.example.org/branch/test;refs/heads/test2');
-          done();
-        });
-      });
-
-      test('test for refs/heads/ being striped from ref', done => {
-        flush(() => {
-          assert.equal(element._stripRefs(element._items[2].ref,
-              element.detailType), 'test2');
-          done();
-        });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-
-      test('Edit HEAD button not admin', done => {
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
-            Promise.resolve({
-              test: {is_owner: false},
-            }));
-        element._determineIfOwner('test').then(() => {
-          assert.equal(element._isOwner, false);
-          assert.equal(getComputedStyle(dom(element.root)
-              .querySelector('.revisionNoEditing')).display, 'inline');
-          assert.equal(getComputedStyle(dom(element.root)
-              .querySelector('.revisionEdit')).display, 'none');
-          done();
-        });
-      });
-
-      test('Edit HEAD button admin', done => {
-        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 = dom(element.root)
-            .querySelector('.revisionWithEditing');
-
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
-            Promise.resolve({
-              test: {is_owner: true},
-            }));
-        sandbox.stub(element, '_handleSaveRevision');
-        element._determineIfOwner('test').then(() => {
-          assert.equal(element._isOwner, true);
-          // The revision container for non-editing enabled row is not visible.
-          assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
-
-          // The revision container for editing enabled row is visible.
-          assert.notEqual(getComputedStyle(dom(element.root)
-              .querySelector('.revisionEdit')).display, 'none');
-
-          // The revision and edit button are visible.
-          assert.notEqual(getComputedStyle(revisionWithEditing).display,
-              'none');
-          assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-          // The input, cancel, and save buttons are not visible.
-          const hiddenElements = dom(element.root)
-              .querySelectorAll('.canEdit .editItem');
-
-          for (const item of hiddenElements) {
-            assert.equal(getComputedStyle(item).display, 'none');
-          }
-
-          MockInteractions.tap(editBtn);
-          flushAsynchronousOperations();
-          // The revision and edit button are not visible.
-          assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
-          assert.equal(getComputedStyle(editBtn).display, 'none');
-
-          // The input, cancel, and save buttons are not visible.
-          for (const item of hiddenElements) {
-            assert.notEqual(getComputedStyle(item).display, 'none');
-          }
-
-          // The revised ref was set correctly
-          assert.equal(element._revisedRef, 'master');
-
-          assert.isFalse(saveBtn.disabled);
-
-          // Delete the ref.
-          element._revisedRef = '';
-          assert.isTrue(saveBtn.disabled);
-
-          // Change the ref to something else
-          element._revisedRef = 'newRef';
-          element._repo = 'test';
-          assert.isFalse(saveBtn.disabled);
-
-          // Save button calls handleSave. since this is stubbed, the edit
-          // section remains open.
-          MockInteractions.tap(saveBtn);
-          assert.isTrue(element._handleSaveRevision.called);
-
-          // When cancel is tapped, the edit secion closes.
-          MockInteractions.tap(cancelBtn);
-          flushAsynchronousOperations();
-
-          // The revision and edit button are visible.
-          assert.notEqual(getComputedStyle(revisionWithEditing).display,
-              'none');
-          assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-          // The input, cancel, and save buttons are not visible.
-          for (const item of hiddenElements) {
-            assert.equal(getComputedStyle(item).display, 'none');
-          }
-          done();
-        });
-      });
-
-      test('_handleSaveRevision with invalid rev', done => {
-        const event = {model: {set: sandbox.stub()}};
-        element._isEditing = true;
-        sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
-            Promise.resolve({
-              status: 400,
-            })
-        );
-
-        element._setRepoHead('test', 'newRef', event).then(() => {
-          assert.isTrue(element._isEditing);
-          assert.isFalse(event.model.set.called);
-          done();
-        });
-      });
-
-      test('_handleSaveRevision with valid rev', done => {
-        const event = {model: {set: sandbox.stub()}};
-        element._isEditing = true;
-        sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
-            Promise.resolve({
-              status: 200,
-            })
-        );
-
-        element._setRepoHead('test', 'newRef', event).then(() => {
-          assert.isFalse(element._isEditing);
-          assert.isTrue(event.model.set.called);
-          done();
-        });
-      });
-
-      test('test _computeItemName', () => {
-        assert.deepEqual(element._computeItemName('branches'), 'Branch');
-        assert.deepEqual(element._computeItemName('tags'), 'Tag');
-      });
-    });
-
-    suite('list with less then 25 branches', () => {
-      setup(done => {
-        branches = _.times(25, branchGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, repo, offset) {
-            return Promise.resolve(branches);
-          },
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'branches',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(
-            element.$.restAPI,
-            'getRepoBranches',
-            () => Promise.resolve(branches));
-        const params = {
-          detail: 'branches',
-          repo: 'test',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[3],
-              25);
-          done();
-        });
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        const response = {status: 404};
-        sandbox.stub(element.$.restAPI, 'getRepoBranches',
-            (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
-              errFn(response);
-            });
-
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        const params = {
-          detail: 'branches',
-          repo: 'test',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params);
-      });
-    });
-  });
-
-  suite('Tags', () => {
-    let element;
-    let tags;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.detailType = 'tags';
-      counter = 0;
-      sandbox.stub(page, 'show');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_computeMessage', () => {
-      let message = 'v2.15-rc1↵-----BEGIN PGP SIGNATURE-----↵Version: GnuPG v' +
-      '1↵↵iQIcBAABAgAGBQJZ27O7AAoJEF/XxZqaEoiMy6kQAMoQCpGr3J6JITI4BVWsr7QM↵xy' +
-      'EcWH5YPUko5EPTbkABHmaVyFmKGkuIQdn6c+NIbqJOk+5XT4oUyRSo1T569HPJ↵3kyxEJi' +
-      'T1ryvp5BIHwdvHx58fjw1+YkiWLZuZq1FFkUYqnWTYCrkv7Fok98pdOmV↵CL1Hgugi5uK8' +
-      '/kxf1M7+Nv6piaZ140pwSb1h6QdAjaZVfaBCnoxlG4LRUqHvEYay↵f4QYgFT67auHIGkZ4' +
-      'moUcsp2Du/1jSsCWL/CPwjPFGbbckVAjLCMT9yD3NKwpEZF↵pfsiZyHI9dL0M+QjVrM+RD' +
-      'HwIIJwra8R0IMkDlQ6MDrFlKNqNBbo588S6UPrm71L↵YuiwWlcrK9ZIybxT6LzbR65Rvez' +
-      'DSitQ+xeIfpZE19/X6BCnvlARLE8k/tC2JksI↵lEZi7Lf3FQdIcwwyt98tJkS9HX9v9jbC' +
-      '5QXifnoj3Li8tHSLuQ1dJCxHQiis6ojI↵OWUFkm0IHBXVNHA2dqYBdM+pL12mlI3wp6Ica' +
-      '4cdEVDwzu+j1xnVSFUa+d+Y2xJF↵7mytuyhHiKG4hm+zbhMv6WD8Q3FoDsJZeLY99l0hYQ' +
-      'SnnkMduFVroIs45pAs8gUA↵RvYla8mm9w/543IJAPzzFarPVLSsSyQ7tJl3UBzjKRNH/rX' +
-      'W+F22qyWD1zyHPUIR↵C00ItmwlAvveImYKpQAH↵=L+K9↵-----END PGP SIGNATURE---' +
-      '--';
-      assert.equal(element._computeMessage(message), 'v2.15-rc1↵');
-      message = 'v2.15-rc1';
-      assert.equal(element._computeMessage(message), 'v2.15-rc1');
-    });
-
-    suite('list of repo tags', () => {
-      setup(done => {
-        tags = _.times(26, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, repo, offset) {
-            return Promise.resolve(tags);
-          },
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('test for tag in the list', done => {
-        flush(() => {
-          assert.equal(element._items[1].ref, 'refs/tags/test2');
-          done();
-        });
-      });
-
-      test('test for tag message in the list', done => {
-        flush(() => {
-          assert.equal(element._items[1].message, 'Annotated tag');
-          done();
-        });
-      });
-
-      test('test for tagger in the tag list', done => {
-        const tagger = {
-          name: 'Test User',
-          email: 'test.user@gmail.com',
-          date: '2017-09-19 14:54:00.000000000',
-          tz: 540,
-        };
-        flush(() => {
-          assert.deepEqual(element._items[1].tagger, tagger);
-          done();
-        });
-      });
-
-      test('test for web links in the tags list', done => {
-        flush(() => {
-          assert.equal(element._items[1].web_links[0].url,
-              'https://git.example.org/tag/test;refs/tags/test2');
-          done();
-        });
-      });
-
-      test('test for refs/tags/ being striped from ref', done => {
-        flush(() => {
-          assert.equal(element._stripRefs(element._items[1].ref,
-              element.detailType), 'test2');
-          done();
-        });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-
-      test('_computeHideTagger', () => {
-        const testObject1 = {
-          tagger: 'test',
-        };
-        assert.equal(element._computeHideTagger(testObject1), '');
-
-        assert.equal(element._computeHideTagger(undefined), 'hide');
-      });
-    });
-
-    suite('list with less then 25 tags', () => {
-      setup(done => {
-        tags = _.times(25, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, project, offset) {
-            return Promise.resolve(tags);
-          },
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(
-            element.$.restAPI,
-            'getRepoTags',
-            () => Promise.resolve(tags));
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[3],
-              25);
-          done();
-        });
-      });
-    });
-
-    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('_handleCreateItem called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateItem');
-        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.dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-        assert.isTrue(element._handleCloseCreate.called);
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        const response = {status: 404};
-        sandbox.stub(element.$.restAPI, 'getRepoTags',
-            (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
-              errFn(response);
-            });
-
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params);
-      });
-    });
-
-    test('test _computeHideDeleteClass', () => {
-      assert.deepEqual(element._computeHideDeleteClass(true, false), 'show');
-      assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
-      assert.deepEqual(element._computeHideDeleteClass(false, false), '');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
new file mode 100644
index 0000000..7727821
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -0,0 +1,550 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-repo-detail-list.js';
+import 'lodash/lodash.js';
+import {page} from '../../../utils/page-wrapper-utils.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-repo-detail-list');
+
+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;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.detailType = 'branches';
+      counter = 0;
+      sinon.stub(page, 'show');
+    });
+
+    suite('list of repo branches', () => {
+      setup(done => {
+        branches = [{
+          ref: 'HEAD',
+          revision: 'master',
+        }].concat(_.times(25, branchGenerator));
+
+        stub('gr-rest-api-interface', {
+          getRepoBranches(num, project, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('test for branch in the list', done => {
+        flush(() => {
+          assert.equal(element._items[2].ref, 'refs/heads/test2');
+          done();
+        });
+      });
+
+      test('test for web links in the branches list', done => {
+        flush(() => {
+          assert.equal(element._items[2].web_links[0].url,
+              'https://git.example.org/branch/test;refs/heads/test2');
+          done();
+        });
+      });
+
+      test('test for refs/heads/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[2].ref,
+              element.detailType), 'test2');
+          done();
+        });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+
+      test('Edit HEAD button not admin', done => {
+        sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+            Promise.resolve({
+              test: {is_owner: false},
+            }));
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, false);
+          assert.equal(getComputedStyle(dom(element.root)
+              .querySelector('.revisionNoEditing')).display, 'inline');
+          assert.equal(getComputedStyle(dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+          done();
+        });
+      });
+
+      test('Edit HEAD button admin', done => {
+        const saveBtn = element.root.querySelector('.saveBtn');
+        const cancelBtn = element.root.querySelector('.cancelBtn');
+        const editBtn = element.root.querySelector('.editBtn');
+        const revisionNoEditing = dom(element.root)
+            .querySelector('.revisionNoEditing');
+        const revisionWithEditing = dom(element.root)
+            .querySelector('.revisionWithEditing');
+
+        sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+            Promise.resolve({
+              test: {is_owner: true},
+            }));
+        sinon.stub(element, '_handleSaveRevision');
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, true);
+          // The revision container for non-editing enabled row is not visible.
+          assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
+
+          // The revision container for editing enabled row is visible.
+          assert.notEqual(getComputedStyle(dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          const hiddenElements = dom(element.root)
+              .querySelectorAll('.canEdit .editItem');
+
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+
+          MockInteractions.tap(editBtn);
+          flush();
+          // The revision and edit button are not visible.
+          assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
+          assert.equal(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (const item of hiddenElements) {
+            assert.notEqual(getComputedStyle(item).display, 'none');
+          }
+
+          // The revised ref was set correctly
+          assert.equal(element._revisedRef, 'master');
+
+          assert.isFalse(saveBtn.disabled);
+
+          // Delete the ref.
+          element._revisedRef = '';
+          assert.isTrue(saveBtn.disabled);
+
+          // Change the ref to something else
+          element._revisedRef = 'newRef';
+          element._repo = 'test';
+          assert.isFalse(saveBtn.disabled);
+
+          // Save button calls handleSave. since this is stubbed, the edit
+          // section remains open.
+          MockInteractions.tap(saveBtn);
+          assert.isTrue(element._handleSaveRevision.called);
+
+          // When cancel is tapped, the edit secion closes.
+          MockInteractions.tap(cancelBtn);
+          flush();
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with invalid rev', done => {
+        const event = {model: {set: sinon.stub()}};
+        element._isEditing = true;
+        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+            Promise.resolve({
+              status: 400,
+            })
+        );
+
+        element._setRepoHead('test', 'newRef', event).then(() => {
+          assert.isTrue(element._isEditing);
+          assert.isFalse(event.model.set.called);
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with valid rev', done => {
+        const event = {model: {set: sinon.stub()}};
+        element._isEditing = true;
+        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+            Promise.resolve({
+              status: 200,
+            })
+        );
+
+        element._setRepoHead('test', 'newRef', event).then(() => {
+          assert.isFalse(element._isEditing);
+          assert.isTrue(event.model.set.called);
+          done();
+        });
+      });
+
+      test('test _computeItemName', () => {
+        assert.deepEqual(element._computeItemName('branches'), 'Branch');
+        assert.deepEqual(element._computeItemName('tags'), 'Tag');
+      });
+    });
+
+    suite('list with less then 25 branches', () => {
+      setup(done => {
+        branches = _.times(25, branchGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoBranches(num, repo, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sinon.stub(
+            element.$.restAPI,
+            'getRepoBranches')
+            .callsFake(() => Promise.resolve(branches));
+        const params = {
+          detail: 'branches',
+          repo: 'test',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[0],
+              'test');
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[1],
+              'test');
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[2],
+              25);
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[3],
+              25);
+          done();
+        });
+      });
+    });
+
+    suite('404', () => {
+      test('fires page-error', done => {
+        const response = {status: 404};
+        sinon.stub(element.$.restAPI, 'getRepoBranches').callsFake(
+            (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
+              errFn(response);
+            });
+
+        element.addEventListener('page-error', e => {
+          assert.deepEqual(e.detail.response, response);
+          done();
+        });
+
+        const params = {
+          detail: 'branches',
+          repo: 'test',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params);
+      });
+    });
+  });
+
+  suite('Tags', () => {
+    let element;
+    let tags;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.detailType = 'tags';
+      counter = 0;
+      sinon.stub(page, 'show');
+    });
+
+    test('_computeMessage', () => {
+      let message = 'v2.15-rc1↵-----BEGIN PGP SIGNATURE-----↵Version: GnuPG v' +
+      '1↵↵iQIcBAABAgAGBQJZ27O7AAoJEF/XxZqaEoiMy6kQAMoQCpGr3J6JITI4BVWsr7QM↵xy' +
+      'EcWH5YPUko5EPTbkABHmaVyFmKGkuIQdn6c+NIbqJOk+5XT4oUyRSo1T569HPJ↵3kyxEJi' +
+      'T1ryvp5BIHwdvHx58fjw1+YkiWLZuZq1FFkUYqnWTYCrkv7Fok98pdOmV↵CL1Hgugi5uK8' +
+      '/kxf1M7+Nv6piaZ140pwSb1h6QdAjaZVfaBCnoxlG4LRUqHvEYay↵f4QYgFT67auHIGkZ4' +
+      'moUcsp2Du/1jSsCWL/CPwjPFGbbckVAjLCMT9yD3NKwpEZF↵pfsiZyHI9dL0M+QjVrM+RD' +
+      'HwIIJwra8R0IMkDlQ6MDrFlKNqNBbo588S6UPrm71L↵YuiwWlcrK9ZIybxT6LzbR65Rvez' +
+      'DSitQ+xeIfpZE19/X6BCnvlARLE8k/tC2JksI↵lEZi7Lf3FQdIcwwyt98tJkS9HX9v9jbC' +
+      '5QXifnoj3Li8tHSLuQ1dJCxHQiis6ojI↵OWUFkm0IHBXVNHA2dqYBdM+pL12mlI3wp6Ica' +
+      '4cdEVDwzu+j1xnVSFUa+d+Y2xJF↵7mytuyhHiKG4hm+zbhMv6WD8Q3FoDsJZeLY99l0hYQ' +
+      'SnnkMduFVroIs45pAs8gUA↵RvYla8mm9w/543IJAPzzFarPVLSsSyQ7tJl3UBzjKRNH/rX' +
+      'W+F22qyWD1zyHPUIR↵C00ItmwlAvveImYKpQAH↵=L+K9↵-----END PGP SIGNATURE---' +
+      '--';
+      assert.equal(element._computeMessage(message), 'v2.15-rc1↵');
+      message = 'v2.15-rc1';
+      assert.equal(element._computeMessage(message), 'v2.15-rc1');
+    });
+
+    suite('list of repo tags', () => {
+      setup(done => {
+        tags = _.times(26, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoTags(num, repo, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('test for tag in the list', done => {
+        flush(() => {
+          assert.equal(element._items[1].ref, 'refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for tag message in the list', done => {
+        flush(() => {
+          assert.equal(element._items[1].message, 'Annotated tag');
+          done();
+        });
+      });
+
+      test('test for tagger in the tag list', done => {
+        const tagger = {
+          name: 'Test User',
+          email: 'test.user@gmail.com',
+          date: '2017-09-19 14:54:00.000000000',
+          tz: 540,
+        };
+        flush(() => {
+          assert.deepEqual(element._items[1].tagger, tagger);
+          done();
+        });
+      });
+
+      test('test for web links in the tags list', done => {
+        flush(() => {
+          assert.equal(element._items[1].web_links[0].url,
+              'https://git.example.org/tag/test;refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for refs/tags/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[1].ref,
+              element.detailType), 'test2');
+          done();
+        });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+
+      test('_computeHideTagger', () => {
+        const testObject1 = {
+          tagger: 'test',
+        };
+        assert.equal(element._computeHideTagger(testObject1), '');
+
+        assert.equal(element._computeHideTagger(undefined), 'hide');
+      });
+    });
+
+    suite('list with less then 25 tags', () => {
+      setup(done => {
+        tags = _.times(25, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoTags(num, project, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sinon.stub(
+            element.$.restAPI,
+            'getRepoTags')
+            .callsFake(() => Promise.resolve(tags));
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[0],
+              'test');
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[1],
+              'test');
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[2],
+              25);
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[3],
+              25);
+          done();
+        });
+      });
+    });
+
+    suite('create new', () => {
+      test('_handleCreateClicked called when create-click fired', () => {
+        sinon.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 = sinon.stub(element.$.createOverlay, 'open');
+        element._handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateItem called when confirm fired', () => {
+        sinon.stub(element, '_handleCreateItem');
+        element.$.createDialog.dispatchEvent(
+            new CustomEvent('confirm', {
+              composed: true, bubbles: true,
+            }));
+        assert.isTrue(element._handleCreateItem.called);
+      });
+
+      test('_handleCloseCreate called when cancel fired', () => {
+        sinon.stub(element, '_handleCloseCreate');
+        element.$.createDialog.dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+        assert.isTrue(element._handleCloseCreate.called);
+      });
+    });
+
+    suite('404', () => {
+      test('fires page-error', done => {
+        const response = {status: 404};
+        sinon.stub(element.$.restAPI, 'getRepoTags').callsFake(
+            (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
+              errFn(response);
+            });
+
+        element.addEventListener('page-error', e => {
+          assert.deepEqual(e.detail.response, response);
+          done();
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params);
+      });
+    });
+
+    test('test _computeHideDeleteClass', () => {
+      assert.deepEqual(element._computeHideDeleteClass(true, false), 'show');
+      assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
+      assert.deepEqual(element._computeHideDeleteClass(false, false), '');
+    });
+  });
+});
+
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
deleted file mode 100644
index 1dd28e8..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
+++ /dev/null
@@ -1,193 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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',
-      },
-
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _path: {
-        type: String,
-        readOnly: true,
-        value: '/admin/repos',
-      },
-      _hasNewRepoName: Boolean,
-      _createNewCapability: {
-        type: Boolean,
-        value: false,
-      },
-      _repos: Array,
-
-      /**
-       * Because  we request one more than the projectsPerPage, _shownProjects
-       * maybe one less than _projects.
-       * */
-      _shownRepos: {
-        type: Array,
-        computed: 'computeShownItems(_repos)',
-      },
-
-      _reposPerPage: {
-        type: Number,
-        value: 25,
-      },
-
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getCreateRepoCapability();
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Repos'},
-      composed: true, bubbles: true,
-    }));
-    this._maybeOpenCreateOverlay(this.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);
-  }
-
-  /**
-   * 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 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.toLowerCase().includes(filter.toLowerCase())
-          );
-          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.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
new file mode 100644
index 0000000..ea1cffb
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -0,0 +1,191 @@
+/**
+ * @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 '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-repo-dialog/gr-create-repo-dialog';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-list_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe, computed} from '@polymer/decorators';
+import {AppElementAdminParams} from '../../gr-app-types';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RepoName, ProjectInfoWithName} from '../../../types/common';
+import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
+import {ProjectState} from '../../../constants/constants';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-list': GrRepoList;
+  }
+}
+
+export interface GrRepoList {
+  $: {
+    restAPI: RestApiService & Element;
+    createOverlay: GrOverlay;
+    createNewModal: GrCreateRepoDialog;
+  };
+}
+
+@customElement('gr-repo-list')
+export class GrRepoList extends ListViewMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  params?: AppElementAdminParams;
+
+  @property({type: Number})
+  _offset?: number;
+
+  @property({type: String})
+  readonly _path = '/admin/repos';
+
+  @property({type: Boolean})
+  _hasNewRepoName = false;
+
+  @property({type: Boolean})
+  _createNewCapability = false;
+
+  @property({type: Array})
+  _repos: ProjectInfoWithName[] = [];
+
+  @property({type: Number})
+  _reposPerPage = 25;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _filter = '';
+
+  @computed('_repos')
+  get _shownRepos() {
+    return this.computeShownItems(this._repos);
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getCreateRepoCapability();
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: 'Repos'},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    this._maybeOpenCreateOverlay(this.params);
+  }
+
+  @observe('params')
+  _paramsChanged(params: AppElementAdminParams) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+
+    return this._getRepos(this._filter, this._reposPerPage, this._offset);
+  }
+
+  /**
+   * Opens the create overlay if the route has a hash 'create'
+   */
+  _maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+    if (params?.openCreateModal) {
+      this.$.createOverlay.open();
+    }
+  }
+
+  _computeRepoUrl(name: string) {
+    return this.getUrl(this._path + '/', name);
+  }
+
+  _computeChangesLink(name: string) {
+    return GerritNav.getUrlForProjectChanges(name as RepoName);
+  }
+
+  _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: string, reposPerPage: number, offset?: number) {
+    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.toLowerCase().includes(filter.toLowerCase())
+      );
+      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(repo: ProjectInfoWithName) {
+    return repo.state === ProjectState.READ_ONLY ? 'Y' : '';
+  }
+
+  _computeWeblink(repo: ProjectInfoWithName) {
+    if (!repo.web_links) {
+      return '';
+    }
+    const webLinks = repo.web_links;
+    return webLinks.length ? webLinks : null;
+  }
+}
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
deleted file mode 100644
index 3681399..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
new file mode 100644
index 0000000..4889845
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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}}"
+          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
deleted file mode 100644
index 4b1a2af..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
+++ /dev/null
@@ -1,221 +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">
-<meta charset="utf-8">
-<title>gr-repo-list</title>
-
-<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>
-    <gr-repo-list></gr-repo-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-list.js';
-import page from 'page/page.mjs';
-
-function createRepo(name, counter) {
-  return {
-    id: `${name}${counter}`,
-    name: `${name}`,
-    state: 'ACTIVE',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://phabricator.example.org/r/project/${name}${counter}`,
-      },
-    ],
-  };
-}
-
-let counter;
-const repoGenerator = () => createRepo('test', ++counter);
-
-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;
-  });
-
-  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', () => {
-    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();
-      });
-    });
-
-    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('filter is case insensitive', async () => {
-      const repoStub = sandbox.stub(element.$.restAPI, 'getRepos');
-      const repos = [createRepo('aSDf', 0)];
-      repoStub.withArgs('asdf').returns(Promise.resolve(repos));
-      element._filter = 'asdf';
-      await element._getRepos('asdf', 25, 0);
-      assert.equal(element._repos.length, 1);
-    });
-  });
-
-  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-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
new file mode 100644
index 0000000..ad8c6dd
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -0,0 +1,202 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-repo-list.js';
+import {page} from '../../../utils/page-wrapper-utils.js';
+import 'lodash/lodash.js';
+
+const basicFixture = fixtureFromElement('gr-repo-list');
+
+function createRepo(name, counter) {
+  return {
+    id: `${name}${counter}`,
+    name: `${name}`,
+    state: 'ACTIVE',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://phabricator.example.org/r/project/${name}${counter}`,
+      },
+    ],
+  };
+}
+
+let counter;
+const repoGenerator = () => createRepo('test', ++counter);
+
+suite('gr-repo-list tests', () => {
+  let element;
+  let repos;
+
+  let value;
+
+  setup(() => {
+    sinon.stub(page, 'show');
+    element = basicFixture.instantiate();
+    counter = 0;
+  });
+
+  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 = sinon.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', () => {
+    let reposFiltered;
+    setup(() => {
+      repos = _.times(25, repoGenerator);
+      reposFiltered = _.times(1, repoGenerator);
+    });
+
+    test('_paramsChanged', done => {
+      sinon.stub(element.$.restAPI, 'getRepos')
+          .callsFake( () => Promise.resolve(repos));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getRepos.lastCall
+            .calledWithExactly('test', 25, 25));
+        done();
+      });
+    });
+
+    test('latest repos requested are always set', done => {
+      const repoStub = sinon.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('filter is case insensitive', async () => {
+      const repoStub = sinon.stub(element.$.restAPI, 'getRepos');
+      const repos = [createRepo('aSDf', 0)];
+      repoStub.withArgs('asdf').returns(Promise.resolve(repos));
+      element._filter = 'asdf';
+      await element._getRepos('asdf', 25, 0);
+      assert.equal(element._repos.length, 1);
+    });
+  });
+
+  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);
+
+      flush();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('create new', () => {
+    test('_handleCreateClicked called when create-click fired', () => {
+      sinon.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 = sinon.stub(element.$.createOverlay, 'open');
+      element._handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateRepo called when confirm fired', () => {
+      sinon.stub(element, '_handleCreateRepo');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCreateRepo.called);
+    });
+
+    test('_handleCloseCreate called when cancel fired', () => {
+      sinon.stub(element, '_handleCloseCreate');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCloseCreate.called);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
new file mode 100644
index 0000000..6a96f55
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
@@ -0,0 +1,44 @@
+/**
+ * @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 contains interfaces shared between
+ * gr-repo-plugin-config.ts and nested editors
+ * (e.g. gr-plugin-config-array-editor.ts)
+ *
+ * This file is required to avoid circular dependencies between files
+ */
+
+import {
+  ConfigArrayParameterInfo,
+  ConfigParameterInfo,
+  ConfigParameterInfoBase,
+} from '../../../types/common';
+
+export interface PluginOption<
+  T extends ConfigParameterInfoBase = ConfigParameterInfo
+> {
+  _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
+  info: T;
+}
+
+export type ArrayPluginOption = PluginOption<ConfigArrayParameterInfo>;
+
+export interface PluginConfigOptionsChangedEventDetail {
+  _key: string;
+  info: ConfigArrayParameterInfo;
+  notifyPath: string;
+}
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
deleted file mode 100644
index c419a53..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrRepoPluginConfig extends mixinBehaviors( [
-  RepoPluginConfig,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  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.*)',
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-    };
-  }
-
-  _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]}; });
-  }
-
-  _isArray(type) {
-    return type === this.ENTRY_TYPES.ARRAY;
-  }
-
-  _isBoolean(type) {
-    return type === this.ENTRY_TYPES.BOOLEAN;
-  }
-
-  _isList(type) {
-    return type === this.ENTRY_TYPES.LIST;
-  }
-
-  _isString(type) {
-    // Treat numbers like strings for simplicity.
-    return type === this.ENTRY_TYPES.STRING ||
-        type === this.ENTRY_TYPES.INT ||
-        type === this.ENTRY_TYPES.LONG;
-  }
-
-  _computeDisabled(disabled, editable) {
-    return disabled || !editable;
-  }
-
-  /**
-   * @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);
-  }
-
-  _handleListChange(e) {
-    const el = 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);
-  }
-
-  _buildConfigChangeInfo(value, _key) {
-    const info = this.pluginData.config[_key];
-    info.value = value;
-    return {
-      _key,
-      info,
-      notifyPath: `${_key}.value`,
-    };
-  }
-
-  _handleArrayChange({detail}) {
-    this._handleChange(detail);
-  }
-
-  _handleChange({_key, info, notifyPath}) {
-    const {name, config} = this.pluginData;
-
-    /** @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}));
-  }
-
-  /**
-   * 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.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
new file mode 100644
index 0000000..e7eb9dc
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -0,0 +1,210 @@
+/**
+ * @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.
+ */
+import '@polymer/iron-input/iron-input';
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-plugin-config_html';
+import {customElement, property} from '@polymer/decorators';
+import {ConfigParameterInfoType} from '../../../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+  ConfigParameterInfo,
+  PluginParameterToConfigParameterInfoMap,
+} from '../../../types/common';
+import {PaperToggleButtonElement} from '@polymer/paper-toggle-button/paper-toggle-button';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {
+  PluginConfigOptionsChangedEventDetail,
+  PluginOption,
+} from './gr-repo-plugin-config-types';
+
+const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
+
+export interface ConfigChangeInfo {
+  _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
+  info: ConfigParameterInfo;
+  notifyPath: string;
+}
+
+export interface PluginData {
+  name: string; // parameterName of PluginParameterToConfigParameterInfoMap
+  config: PluginParameterToConfigParameterInfoMap;
+}
+
+export interface PluginConfigChangeDetail {
+  name: string; // parameterName of PluginParameterToConfigParameterInfoMap
+  config: PluginParameterToConfigParameterInfoMap;
+  notifyPath: string;
+}
+
+@customElement('gr-repo-plugin-config')
+class GrRepoPluginConfig extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the plugin config changes.
+   *
+   * @event plugin-config-changed
+   */
+
+  @property({type: Object})
+  pluginData?: PluginData;
+
+  @property({
+    type: Array,
+    computed: '_computePluginConfigOptions(pluginData.*)',
+  })
+  _pluginConfigOptions!: PluginOption[]; // _computePluginConfigOptions never returns null
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  _computePluginConfigOptions(
+    dataRecord: PolymerDeepPropertyChange<PluginData, PluginData>
+  ): PluginOption[] {
+    if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
+      return [];
+    }
+    const config = dataRecord.base.config;
+    return Object.keys(config).map(_key => {
+      return {_key, info: config[_key]};
+    });
+  }
+
+  _isArray(type: ConfigParameterInfoType) {
+    return type === ConfigParameterInfoType.ARRAY;
+  }
+
+  _isBoolean(type: ConfigParameterInfoType) {
+    return type === ConfigParameterInfoType.BOOLEAN;
+  }
+
+  _isList(type: ConfigParameterInfoType) {
+    return type === ConfigParameterInfoType.LIST;
+  }
+
+  _isString(type: ConfigParameterInfoType) {
+    // Treat numbers like strings for simplicity.
+    return (
+      type === ConfigParameterInfoType.STRING ||
+      type === ConfigParameterInfoType.INT ||
+      type === ConfigParameterInfoType.LONG
+    );
+  }
+
+  _computeDisabled(disabled: boolean, editable: boolean) {
+    return disabled || !editable;
+  }
+
+  _computeChecked(value = 'false') {
+    return JSON.parse(value) as boolean;
+  }
+
+  _handleStringChange(e: Event) {
+    const el = (dom(e) as EventApi).localTarget as IronInputElement;
+    // In the template, the data-option-key is assigned to each editor
+    const _key = el.getAttribute('data-option-key')!;
+    const configChangeInfo = this._buildConfigChangeInfo(el.value, _key);
+    this._handleChange(configChangeInfo);
+  }
+
+  _handleListChange(e: Event) {
+    const el = (dom(e) as EventApi).localTarget as HTMLOptionElement;
+    // In the template, the data-option-key is assigned to each editor
+    const _key = el.getAttribute('data-option-key')!;
+    const configChangeInfo = this._buildConfigChangeInfo(el.value, _key);
+    this._handleChange(configChangeInfo);
+  }
+
+  _handleBooleanChange(e: Event) {
+    const el = (dom(e) as EventApi).localTarget as PaperToggleButtonElement;
+    // In the template, the data-option-key is assigned to each editor
+    const _key = el.getAttribute('data-option-key')!;
+    const configChangeInfo = this._buildConfigChangeInfo(
+      JSON.stringify(el.checked),
+      _key
+    );
+    this._handleChange(configChangeInfo);
+  }
+
+  _buildConfigChangeInfo(
+    value: string | null | undefined,
+    _key: string
+  ): ConfigChangeInfo {
+    // If pluginData is not set, editors are not created and this method
+    // can't be called
+    const info = this.pluginData!.config[_key];
+    info.value = value !== null ? value : undefined;
+    return {
+      _key,
+      info,
+      notifyPath: `${_key}.value`,
+    };
+  }
+
+  _handleArrayChange(e: CustomEvent<PluginConfigOptionsChangedEventDetail>) {
+    this._handleChange(e.detail);
+  }
+
+  _handleChange({_key, info, notifyPath}: ConfigChangeInfo) {
+    // If pluginData is not set, editors are not created and this method
+    // can't be called
+    const {name, config} = this.pluginData!;
+
+    /** @type {Object} */
+    const detail: PluginConfigChangeDetail = {
+      name,
+      config: {...config, [_key]: info},
+      notifyPath: `${name}.${notifyPath}`,
+    };
+
+    this.dispatchEvent(
+      new CustomEvent(PLUGIN_CONFIG_CHANGED_EVENT_NAME, {
+        detail,
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapPluginBoolean(e: Event) {
+    e.preventDefault();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-plugin-config': 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
deleted file mode 100644
index 937e67c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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]]"
-                disabled$="[[_computeDisabled(disabled, option.info.editable)]]"
-              ></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(disabled, 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(disabled, 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(disabled, option.info.editable)]]"
-              >
-                <input
-                  is="iron-input"
-                  value="[[option.info.value]]"
-                  on-input="_handleStringChange"
-                  data-option-key$="[[option._key]]"
-                  disabled$="[[_computeDisabled(disabled, 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_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts
new file mode 100644
index 0000000..208e042
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts
@@ -0,0 +1,115 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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]]"
+                disabled$="[[_computeDisabled(disabled, option.info.editable)]]"
+              ></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(disabled, 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(disabled, 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(disabled, option.info.editable)]]"
+              >
+                <input
+                  is="iron-input"
+                  value="[[option.info.value]]"
+                  on-input="_handleStringChange"
+                  data-option-key$="[[option._key]]"
+                  disabled$="[[_computeDisabled(disabled, 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
deleted file mode 100644
index 121d029..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
+++ /dev/null
@@ -1,186 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-repo-plugin-config></gr-repo-plugin-config>
-  </template>
-</test-fixture>
-
-<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(false, true));
-    assert.isTrue(element._computeDisabled(false, undefined));
-    assert.isTrue(element._computeDisabled(false, null));
-    assert.isTrue(element._computeDisabled(false, false));
-    assert.isTrue(element._computeDisabled(true, true));
-  });
-
-  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(() => {
-      changeStub = sandbox.stub(element, '_handleChange');
-      buildStub = sandbox.stub(element, '_buildConfigChangeInfo');
-    });
-
-    test('ARRAY type option', () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'test', type: 'ARRAY', editable: true}},
-      };
-      flushAsynchronousOperations();
-
-      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');
-    });
-
-    test('BOOLEAN type option', () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'true', type: 'BOOLEAN', editable: true}},
-      };
-      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', editable: true}},
-      };
-      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', editable: true, 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-plugin-config/gr-repo-plugin-config_test.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
new file mode 100644
index 0000000..a5c7dfe
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
@@ -0,0 +1,168 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-plugin-config.js';
+
+const basicFixture = fixtureFromElement('gr-repo-plugin-config');
+
+suite('gr-repo-plugin-config tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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(false, true));
+    assert.isTrue(element._computeDisabled(false, undefined));
+    assert.isTrue(element._computeDisabled(false, null));
+    assert.isTrue(element._computeDisabled(false, false));
+    assert.isTrue(element._computeDisabled(true, true));
+  });
+
+  test('_handleChange', () => {
+    const eventStub = sinon.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(() => {
+      changeStub = sinon.stub(element, '_handleChange');
+      buildStub = sinon.stub(element, '_buildConfigChangeInfo');
+    });
+
+    test('ARRAY type option', () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test', type: 'ARRAY', editable: true}},
+      };
+      flush();
+
+      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');
+    });
+
+    test('BOOLEAN type option', () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'true', type: 'BOOLEAN', editable: true}},
+      };
+      flush();
+
+      const toggle = element.shadowRoot
+          .querySelector('paper-toggle-button');
+      assert.ok(toggle);
+      toggle.click();
+      flush();
+
+      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', editable: true}},
+      };
+      flush();
+
+      const input = element.shadowRoot
+          .querySelector('input');
+      assert.ok(input);
+      input.value = 'newTest';
+      input.dispatchEvent(new Event('input'));
+      flush();
+
+      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', editable: true, permitted_values},
+        },
+      };
+      flush();
+
+      const select = element.shadowRoot
+          .querySelector('select');
+      assert.ok(select);
+      select.value = 'newTest';
+      select.dispatchEvent(new Event(
+          'change', {bubbles: true, composed: true}));
+      flush();
+
+      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');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
deleted file mode 100644
index 05ae73d..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 STATES = {
-  active: {value: 'ACTIVE', label: 'Active'},
-  readOnly: {value: 'READ_ONLY', label: 'Read Only'},
-  hidden: {value: 'HIDDEN', label: 'Hidden'},
-};
-
-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',
-  },
-};
-
-/**
- * @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,
-
-      _configChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-        observer: '_loggedInChanged',
-      },
-      /** @type {?} */
-      _repoConfig: Object,
-      /** @type {?} */
-      _pluginData: {
-        type: Array,
-        computed: '_computePluginData(_repoConfig.plugin_config.*)',
-      },
-      _readOnly: {
-        type: Boolean,
-        value: true,
-      },
-      _states: {
-        type: Array,
-        value() {
-          return Object.values(STATES);
-        },
-      },
-      _submitTypes: {
-        type: Array,
-        value() {
-          return Object.values(SUBMIT_TYPES);
-        },
-      },
-      _schemes: {
-        type: Array,
-        value() { return []; },
-        computed: '_computeSchemes(_schemesObj)',
-        observer: '_schemesChanged',
-      },
-      _selectedCommand: {
-        type: String,
-        value: 'Clone',
-      },
-      _selectedScheme: String,
-      _schemesObj: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handleConfigChanged(_repoConfig.*)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadRepo();
-
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: this.repo},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _computePluginData(configRecord) {
-    if (!configRecord ||
-        !configRecord.base) { return []; }
-
-    const pluginConfig = configRecord.base;
-    return Object.keys(pluginConfig)
-        .map(name => { return {name, config: pluginConfig[name]}; });
-  }
-
-  _loadRepo() {
-    if (!this.repo) { return Promise.resolve(); }
-
-    const promises = [];
-
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-
-    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;
-        });
-      }
-    }));
-
-    promises.push(this.$.restAPI.getProjectConfig(this.repo, errFn)
-        .then(config => {
-          if (!config) { 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))),
-      });
-    }
-    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.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
new file mode 100644
index 0000000..101c77a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -0,0 +1,455 @@
+/**
+ * @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-autogrow-textarea/iron-autogrow-textarea';
+import '@polymer/iron-input/iron-input';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-download-commands/gr-download-commands';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+  RestApiService,
+  ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ConfigInfo,
+  RepoName,
+  InheritedBooleanInfo,
+  SchemesInfoMap,
+  ConfigInput,
+  PluginParameterToConfigParameterInfoMap,
+  PluginNameToPluginParametersMap,
+} from '../../../types/common';
+import {PluginData} from '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {ProjectState} from '../../../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const STATES = {
+  active: {value: ProjectState.ACTIVE, label: 'Active'},
+  readOnly: {value: ProjectState.READ_ONLY, label: 'Read Only'},
+  hidden: {value: ProjectState.HIDDEN, label: 'Hidden'},
+};
+
+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',
+  },
+};
+
+export interface GrRepo {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-repo')
+export class GrRepo extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  repo?: RepoName;
+
+  @property({type: Boolean})
+  _configChanged = false;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean, observer: '_loggedInChanged'})
+  _loggedIn = false;
+
+  @property({type: Object})
+  _repoConfig?: ConfigInfo;
+
+  @property({
+    type: Array,
+    computed: '_computePluginData(_repoConfig.plugin_config.*)',
+  })
+  _pluginData?: PluginData[];
+
+  @property({type: Boolean})
+  _readOnly = true;
+
+  @property({type: Array})
+  _states = Object.values(STATES);
+
+  @property({
+    type: Array,
+    computed: '_computeSchemes(_schemesDefault, _schemesObj)',
+    observer: '_schemesChanged',
+  })
+  _schemes: string[] = [];
+
+  // This is workaround to have _schemes with default value [],
+  // because assignment doesn't work when property has a computed attribute.
+  @property({type: Array})
+  _schemesDefault: string[] = [];
+
+  @property({type: String})
+  _selectedCommand = 'Clone';
+
+  @property({type: String})
+  _selectedScheme?: string;
+
+  @property({type: Object})
+  _schemesObj?: SchemesInfoMap;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadRepo();
+
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: this.repo},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _computePluginData(
+    configRecord: PolymerDeepPropertyChange<
+      PluginNameToPluginParametersMap,
+      PluginNameToPluginParametersMap
+    >
+  ) {
+    if (!configRecord || !configRecord.base) {
+      return [];
+    }
+
+    const pluginConfig = configRecord.base;
+    return Object.keys(pluginConfig).map(name => {
+      return {name, config: pluginConfig[name]};
+    });
+  }
+
+  _loadRepo() {
+    if (!this.repo) {
+      return Promise.resolve();
+    }
+
+    const promises = [];
+
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+
+    promises.push(
+      this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+        if (loggedIn) {
+          const repo = this.repo;
+          if (!repo) throw new Error('undefined repo');
+          this.$.restAPI.getRepoAccess(repo).then(access => {
+            if (!access || this.repo !== repo) {
+              return;
+            }
+
+            // If the user is not an owner, is_owner is not a property.
+            this._readOnly = !access[repo].is_owner;
+          });
+        }
+      })
+    );
+
+    promises.push(
+      this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+        if (!config) {
+          return;
+        }
+
+        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;
+        }
+
+        this._schemesObj = config.download.schemes;
+      })
+    );
+
+    return Promise.all(promises);
+  }
+
+  _computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  _computeHideClass(arr?: PluginData[] | string[]) {
+    return !arr || !arr.length ? 'hide' : '';
+  }
+
+  _loggedInChanged(_loggedIn?: boolean) {
+    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: InheritedBooleanInfo) {
+    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: ConfigInfo) {
+    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) {
+      inheritLabel = `Inherit (${type.inherited_value})`;
+      for (const val of allValues) {
+        if (val.value === type.inherited_value) {
+          inheritLabel = `Inherit (${val.label})`;
+          break;
+        }
+      }
+    }
+    return [
+      {
+        label: inheritLabel,
+        value: 'INHERIT',
+      },
+      ...allValues,
+    ];
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
+    const configInputObj: ConfigInput = {};
+    for (const configKey of Object.keys(repoConfig)) {
+      const key = configKey as keyof ConfigInfo;
+      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.plugin_config;
+      } else if (typeof repoConfig[key] === 'object') {
+        const repoConfigObj: any = repoConfig[key];
+        if (repoConfigObj.configured_value) {
+          configInputObj[key as keyof ConfigInput] =
+            repoConfigObj.configured_value;
+        }
+      } else {
+        configInputObj[key as keyof ConfigInput] = repoConfig[key] as any;
+      }
+    }
+    return configInputObj;
+  }
+
+  _handleSaveRepoConfig() {
+    if (!this._repoConfig || !this.repo)
+      return Promise.reject(new Error('undefined repoConfig or repo'));
+    return this.$.restAPI
+      .saveRepoConfig(
+        this.repo,
+        this._formatRepoConfigForSave(this._repoConfig)
+      )
+      .then(() => {
+        this._configChanged = false;
+      });
+  }
+
+  @observe('_repoConfig.*')
+  _handleConfigChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._configChanged = true;
+  }
+
+  _computeButtonDisabled(readOnly: boolean, configChanged: boolean) {
+    return readOnly || !configChanged;
+  }
+
+  _computeHeaderClass(configChanged: boolean) {
+    return configChanged ? 'edited' : '';
+  }
+
+  _computeSchemes(schemesDefault: string[], schemesObj?: SchemesInfoMap) {
+    return !schemesObj ? schemesDefault : Object.keys(schemesObj);
+  }
+
+  _schemesChanged(schemes: string[]) {
+    if (schemes.length === 0) {
+      return;
+    }
+    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
+      this._selectedScheme = schemes.sort()[0];
+    }
+  }
+
+  _computeCommands(
+    repo?: RepoName,
+    schemesObj?: SchemesInfoMap,
+    _selectedScheme?: string
+  ) {
+    if (!schemesObj || !repo || !_selectedScheme) {
+      return [];
+    }
+    const commands = [];
+    let commandObj: {[title: string]: string} = {};
+    if (hasOwnProperty(schemesObj, _selectedScheme)) {
+      commandObj = schemesObj[_selectedScheme].clone_commands;
+    }
+    for (const title in commandObj) {
+      if (!hasOwnProperty(commandObj, 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: InheritedBooleanInfo) {
+    return config ? 'showConfig' : '';
+  }
+
+  _computeChangesUrl(name: RepoName) {
+    return GerritNav.getUrlForProjectChanges(name);
+  }
+
+  _handlePluginConfigChanged({
+    detail: {name, config, notifyPath},
+  }: {
+    detail: {
+      name: string;
+      config: PluginParameterToConfigParameterInfoMap;
+      notifyPath: string;
+    };
+  }) {
+    if (this._repoConfig?.plugin_config) {
+      this._repoConfig.plugin_config[name] = config;
+      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo': 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
deleted file mode 100644
index 4c0833f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
+++ /dev/null
@@ -1,438 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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]]"
-                disabled$="[[_readOnly]]"
-              ></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_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
new file mode 100644
index 0000000..3d96f98
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
@@ -0,0 +1,441 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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="heading-1">
+        [[repo]]
+      </h1>
+      <hr />
+      <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" class="heading-2">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$="heading-2 [[_computeHeaderClass(_configChanged)]]"
+      >
+        Configurations
+      </h2>
+      <div id="form">
+        <fieldset>
+          <h3 id="Description" class="heading-3">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" class="heading-3">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" class="heading-3">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 class="heading-3">Plugins</h3>
+            <template is="dom-repeat" items="[[_pluginData]]" as="data">
+              <gr-repo-plugin-config
+                plugin-data="[[data]]"
+                disabled$="[[_readOnly]]"
+              ></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
deleted file mode 100644
index 58b488a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
+++ /dev/null
@@ -1,400 +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">
-<meta charset="utf-8">
-<title>gr-repo</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-repo></gr-repo>
-  </template>
-</test-fixture>
-
-<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: {}};
-
-  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',
-        () => Promise.resolve(repoConf));
-  });
-
-  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('_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');
-  });
-
-  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('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();
-    });
-
-    element._loadRepo();
-  });
-
-  suite('admin', () => {
-    setup(() => {
-      element.repo = REPO;
-      sandbox.stub(element, '_getLoggedIn', () => Promise.resolve(true));
-      sandbox.stub(
-          element.$.restAPI,
-          'getRepoAccess',
-          () => Promise.resolve({'test-repo': {is_owner: true}}));
-    });
-
-    test('all form elements are enabled', done => {
-      element._loadRepo().then(() => {
-        flushAsynchronousOperations();
-        const formFields = getFormFields();
-        for (const field of formFields) {
-          assert.isFalse(field.hasAttribute('disabled'));
-        }
-        assert.isFalse(element._loading);
-        done();
-      });
-    });
-
-    test('state gets set correctly', done => {
-      element._loadRepo().then(() => {
-        assert.equal(element._repoConfig.state, 'ACTIVE');
-        assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-        done();
-      });
-    });
-
-    test('inherited submit type value is calculated correctly', done => {
-      element
-          ._loadRepo().then(() => {
-            const sel = element.$.submitTypeSelect;
-            assert.equal(sel.bindValue, 'INHERIT');
-            assert.equal(
-                sel.nativeSelect.options[0].text,
-                'Inherit (Merge if necessary)'
-            );
-            done();
-          });
-    });
-
-    test('fields update and save correctly', () => {
-      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',
-      };
-
-      const saveStub = sandbox.stub(element.$.restAPI, 'saveRepoConfig'
-          , () => Promise.resolve({}));
-
-      const button = dom(element.root).querySelector('gr-button');
-
-      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;
-
-        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));
-        });
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
new file mode 100644
index 0000000..93a9d64
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
@@ -0,0 +1,381 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-repo.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+const basicFixture = fixtureFromElement('gr-repo');
+
+suite('gr-repo tests', () => {
+  let element;
+
+  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: {}};
+
+  function getFormFields() {
+    const selects = Array.from(
+        element.root.querySelectorAll('select'));
+    const textareas = Array.from(
+        element.root.querySelectorAll('iron-autogrow-textarea'));
+    const inputs = Array.from(
+        element.root.querySelectorAll('input'));
+    return inputs.concat(textareas).concat(selects);
+  }
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getConfig() {
+        return Promise.resolve({download: {}});
+      },
+    });
+    element = basicFixture.instantiate();
+    repoStub = sinon.stub(
+        element.$.restAPI,
+        'getProjectConfig')
+        .callsFake(() => Promise.resolve(repoConf));
+  });
+
+  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 = sinon.stub(element, 'notifyPath');
+    element._repoConfig = {plugin_config: {}};
+    element._handlePluginConfigChanged({detail: {
+      name: 'test',
+      config: 'data',
+      notifyPath: 'path',
+    }});
+    flush();
+
+    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('download commands visibility', () => {
+    element._loading = false;
+    flush();
+    assert.isTrue(element.$.downloadContent.classList.contains('hide'));
+    assert.isTrue(getComputedStyle(element.$.downloadContent)
+        .display == 'none');
+    element._schemesObj = SCHEMES;
+    flush();
+    assert.isFalse(element.$.downloadContent.classList.contains('hide'));
+    assert.isFalse(getComputedStyle(element.$.downloadContent)
+        .display == 'none');
+  });
+
+  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;
+    sinon.stub(element, '_getLoggedIn').callsFake(() => Promise.resolve(true));
+    sinon.stub(
+        element.$.restAPI,
+        'getRepoAccess')
+        .callsFake(() => 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(() => {
+      flush();
+      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};
+    sinon.stub(
+        element.$.restAPI, 'getProjectConfig').callsFake((repo, errFn) => {
+      errFn(response);
+    });
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element._loadRepo();
+  });
+
+  suite('admin', () => {
+    setup(() => {
+      element.repo = REPO;
+      sinon.stub(element, '_getLoggedIn')
+          .callsFake(() => Promise.resolve(true));
+      sinon.stub(
+          element.$.restAPI,
+          'getRepoAccess')
+          .callsFake(() => Promise.resolve({'test-repo': {is_owner: true}}));
+    });
+
+    test('all form elements are enabled', done => {
+      element._loadRepo().then(() => {
+        flush();
+        const formFields = getFormFields();
+        for (const field of formFields) {
+          assert.isFalse(field.hasAttribute('disabled'));
+        }
+        assert.isFalse(element._loading);
+        done();
+      });
+    });
+
+    test('state gets set correctly', done => {
+      element._loadRepo().then(() => {
+        assert.equal(element._repoConfig.state, 'ACTIVE');
+        assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
+        done();
+      });
+    });
+
+    test('inherited submit type value is calculated correctly', done => {
+      element
+          ._loadRepo().then(() => {
+            const sel = element.$.submitTypeSelect;
+            assert.equal(sel.bindValue, 'INHERIT');
+            assert.equal(
+                sel.nativeSelect.options[0].text,
+                'Inherit (Merge if necessary)'
+            );
+            done();
+          });
+    });
+
+    test('fields update and save correctly', () => {
+      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',
+      };
+
+      const saveStub = sinon.stub(element.$.restAPI, 'saveRepoConfig')
+          .callsFake(() => Promise.resolve({}));
+
+      const button = element.root.querySelector('gr-button');
+
+      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;
+
+        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));
+        });
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index 234015a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ /dev/null
@@ -1,289 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 the rule has been modified or removed.
- *
- * @event access-modified
- */
-
-/**
- * Fired when a rule that was previously added was removed.
- *
- * @event added-rule-removed
- */
-
-const PRIORITY_OPTIONS = [
-  'BATCH',
-  'INTERACTIVE',
-];
-
-const Action = {
-  ALLOW: 'ALLOW',
-  DENY: 'DENY',
-  BLOCK: 'BLOCK',
-};
-
-const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.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 FORCE_EDIT_OPTIONS = [
-  {
-    name: 'No Force Edit',
-    value: false,
-  },
-  {
-    name: 'Force Edit',
-    value: true,
-  },
-];
-
-/**
- * @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,
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_handleEditingChanged',
-      },
-      groupId: String,
-      groupName: String,
-      permission: String,
-      /** @type {?} */
-      rule: {
-        type: Object,
-        notify: true,
-      },
-      section: String,
-
-      _deleted: {
-        type: Boolean,
-        value: false,
-      },
-      _originalRuleValues: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handleValueChange(rule.value.*)',
-    ];
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('access-saved',
-        () => this._handleAccessSaved());
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    // Called on ready rather than the observer because when new rules are
-    // added, the observer is triggered prior to being ready.
-    if (!this.rule) { return; } // Check needed for test purposes.
-    this._setupValues(this.rule);
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    if (!this.rule) { return; } // Check needed for test purposes.
-    if (!this._originalRuleValues) {
-      // Observer _handleValueChange is called after the ready()
-      // 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.
-    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 [];
-      }
-    } 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));
-  }
-
-  _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}));
-  }
-
-  _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;
-  }
-
-  _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);
-  }
-}
-
-customElements.define(GrRuleEditor.is, GrRuleEditor);
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
new file mode 100644
index 0000000..8843933
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -0,0 +1,316 @@
+/**
+ * @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-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-rule-editor_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {AccessPermissionId} from '../../../utils/access-util';
+import {property, customElement, observe} from '@polymer/decorators';
+
+/**
+ * Fired when the rule has been modified or removed.
+ *
+ * @event access-modified
+ */
+
+/**
+ * Fired when a rule that was previously added was removed.
+ *
+ * @event added-rule-removed
+ */
+
+const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+
+const Action = {
+  ALLOW: 'ALLOW',
+  DENY: 'DENY',
+  BLOCK: 'BLOCK',
+};
+
+const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.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 FORCE_EDIT_OPTIONS = [
+  {
+    name: 'No Force Edit',
+    value: false,
+  },
+  {
+    name: 'Force Edit',
+    value: true,
+  },
+];
+
+interface Rule {
+  value: RuleValue;
+}
+
+interface RuleValue {
+  min?: number;
+  max?: number;
+  force?: boolean;
+  action?: string;
+  added?: boolean;
+  modified?: boolean;
+  deleted?: boolean;
+}
+
+interface RuleLabel {
+  values: RuleLabelValue[];
+}
+
+interface RuleLabelValue {
+  value: number;
+  text: string;
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-rule-editor': GrRuleEditor;
+  }
+}
+
+@customElement('gr-rule-editor')
+export class GrRuleEditor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean})
+  hasRange?: boolean;
+
+  @property({type: Object})
+  label?: RuleLabel;
+
+  @property({type: Boolean, observer: '_handleEditingChanged'})
+  editing = false;
+
+  @property({type: String})
+  groupId?: string;
+
+  @property({type: String})
+  groupName?: string;
+
+  // This is required value for this component
+  @property({type: String})
+  permission!: AccessPermissionId;
+
+  @property({type: Object, notify: true})
+  rule?: Rule;
+
+  @property({type: String})
+  section?: string;
+
+  @property({type: Boolean})
+  _deleted = false;
+
+  @property({type: Object})
+  _originalRuleValues?: RuleValue;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-saved', () => this._handleAccessSaved());
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    // Called on ready rather than the observer because when new rules are
+    // added, the observer is triggered prior to being ready.
+    if (!this.rule) {
+      return;
+    } // Check needed for test purposes.
+    this._setupValues(this.rule);
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    // Check needed for test purposes.
+    if (!this._originalRuleValues && this.rule) {
+      // 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: Rule) {
+    if (!rule.value) {
+      this._setDefaultRuleValues();
+    }
+  }
+
+  _computeForce(permission: AccessPermissionId, action: string) {
+    if (AccessPermissionId.PUSH === permission && action !== Action.DENY) {
+      return true;
+    }
+
+    return AccessPermissionId.EDIT_TOPIC_NAME === permission;
+  }
+
+  _computeForceClass(permission: AccessPermissionId, action: string) {
+    return this._computeForce(permission, action) ? 'force' : '';
+  }
+
+  _computeGroupPath(group: string) {
+    return `${getBaseUrl()}/admin/groups/${encodeURL(group, true)}`;
+  }
+
+  _handleAccessSaved() {
+    if (!this.rule) return;
+    // Set a new 'original' value to keep track of after the value has been
+    // saved.
+    this._setOriginalRuleValues(this.rule.value);
+  }
+
+  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+    // Ignore when editing gets set initially.
+    if (!editingOld) {
+      return;
+    }
+    // Restore original values if no longer editing.
+    if (!editing) {
+      this._handleUndoChange();
+    }
+  }
+
+  _computeSectionClass(editing: boolean, deleted: boolean) {
+    const classList = [];
+    if (editing) {
+      classList.push('editing');
+    }
+    if (deleted) {
+      classList.push('deleted');
+    }
+    return classList.join(' ');
+  }
+
+  _computeForceOptions(permission: string, action: string) {
+    if (permission === AccessPermissionId.PUSH) {
+      if (action === Action.ALLOW) {
+        return ForcePushOptions.ALLOW;
+      } else if (action === Action.BLOCK) {
+        return ForcePushOptions.BLOCK;
+      } else {
+        return [];
+      }
+    } else if (permission === AccessPermissionId.EDIT_TOPIC_NAME) {
+      return FORCE_EDIT_OPTIONS;
+    }
+    return [];
+  }
+
+  _getDefaultRuleValues(permission: AccessPermissionId, label?: RuleLabel) {
+    const ruleAction = Action.ALLOW;
+    const value: RuleValue = {};
+    if (permission === AccessPermissionId.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)
+    );
+  }
+
+  _computeOptions(permission: string) {
+    if (permission === 'priority') {
+      return PRIORITY_OPTIONS;
+    }
+    return DROPDOWN_OPTIONS;
+  }
+
+  _handleRemoveRule() {
+    if (!this.rule) return;
+    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() {
+    if (!this.rule) return;
+    this._deleted = false;
+    delete this.rule.value.deleted;
+  }
+
+  _handleUndoChange() {
+    if (!this.rule) return;
+    // 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', {...this._originalRuleValues});
+    this._deleted = false;
+    delete this.rule.value.deleted;
+    delete this.rule.value.modified;
+  }
+
+  @observe('rule.value.*')
+  _handleValueChange() {
+    if (!this._originalRuleValues || !this.rule) {
+      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: RuleValue) {
+    this._originalRuleValues = {...value};
+  }
+}
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
deleted file mode 100644
index 3e4f9d4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
new file mode 100644
index 0000000..98403e0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
@@ -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';
+
+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
deleted file mode 100644
index f096eed..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ /dev/null
@@ -1,626 +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">
-<meta charset="utf-8">
-<title>gr-rule-editor</title>
-
-<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>
-    <gr-rule-editor></gr-rule-editor>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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},
-            ],
-          };
-
-          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 = '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(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/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
new file mode 100644
index 0000000..b0065d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
@@ -0,0 +1,604 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-rule-editor.js';
+
+const basicFixture = fixtureFromElement('gr-rule-editor');
+
+suite('gr-rule-editor tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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);
+
+          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'};
+      sinon.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 = sinon.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);
+      flush();
+      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(element.root.querySelector('#labelMin'));
+      assert.isNotOk(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';
+      flush();
+      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 = 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);
+      flush();
+      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;
+      flush();
+      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 = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      MockInteractions.tap(element.$.removeBtn);
+      flush();
+      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);
+      flush();
+      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(
+          element.root.querySelector('#labelMin').bindValue,
+          element.rule.value.min);
+      assert.equal(
+          element.root.querySelector('#labelMax').bindValue,
+          element.rule.value.max);
+      assert.isFalse(element.$.force.classList.contains('force'));
+    });
+
+    test('modify value', () => {
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      assert.isNotOk(element.rule.value.modified);
+      element.root.querySelector('#labelMin').bindValue = 1;
+      flush();
+      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 => {
+      sinon.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);
+      flush();
+      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(
+            element.root.querySelector('#labelMin').bindValue,
+            expectedRuleValue.min);
+        assert.equal(
+            element.root.querySelector('#labelMax').bindValue,
+            expectedRuleValue.max);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.root.querySelector('#labelMin').bindValue = 1;
+      flush();
+      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);
+      flush();
+      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(
+          element.root.querySelector('#force').bindValue,
+          element.rule.value.force);
+      assert.isNotOk(element.root.querySelector('#labelMin'));
+      assert.isNotOk(element.root.querySelector('#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = false;
+      flush();
+      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);
+      flush();
+      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;
+      flush();
+      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);
+      flush();
+      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(
+          element.root.querySelector('#force').bindValue,
+          element.rule.value.force);
+      assert.isNotOk(element.root.querySelector('#labelMin'));
+      assert.isNotOk(element.root.querySelector('#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = false;
+      flush();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+});
+
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
deleted file mode 100644
index da13492..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ /dev/null
@@ -1,249 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../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';
-
-const CHANGE_SIZE = {
-  XS: 10,
-  SMALL: 50,
-  MEDIUM: 250,
-  LARGE: 1000,
-};
-
-/**
- * @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,
-      },
-
-      /** @type {?} */
-      change: Object,
-      changeURL: {
-        type: String,
-        computed: '_computeChangeURL(change)',
-      },
-      statuses: {
-        type: Array,
-        computed: 'changeStatuses(change)',
-      },
-      showStar: {
-        type: Boolean,
-        value: false,
-      },
-      showNumber: Boolean,
-      _changeSize: {
-        type: String,
-        computed: '_computeChangeSize(change)',
-      },
-      _dynamicCellEndpoints: {
-        type: Array,
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    pluginLoader.awaitPluginsLoaded().then(() => {
-      this._dynamicCellEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-list-item-cell');
-    });
-  }
-
-  _computeChangeURL(change) {
-    return GerritNav.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;
-  }
-
-  _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 ''; }
-    if (label.approved) {
-      return '✓';
-    }
-    if (label.rejected) {
-      return '✕';
-    }
-    if (label.value > 0) {
-      return '+' + label.value;
-    }
-    if (label.value < 0) {
-      return label.value;
-    }
-    return '';
-  }
-
-  _computeRepoUrl(change) {
-    return GerritNav.getUrlForProjectChanges(change.project, true,
-        change.internalHost);
-  }
-
-  _computeRepoBranchURL(change) {
-    return GerritNav.getUrlForBranch(change.branch, change.project, null,
-        change.internalHost);
-  }
-
-  _computeTopicURL(change) {
-    if (!change.topic) { return ''; }
-    return GerritNav.getUrlForTopic(change.topic, 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;
-  }
-
-  _computeSizeTooltip(change) {
-    if (change.insertions + change.deletions === 0 ||
-        isNaN(change.insertions + change.deletions)) {
-      return 'Size unknown';
-    } else {
-      return `+${change.insertions}, -${change.deletions}`;
-    }
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
new file mode 100644
index 0000000..d70e891
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -0,0 +1,446 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../styles/gr-change-list-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-change-star/gr-change-star';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-list-item_html';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {truncatePath} from '../../../utils/path-list-util';
+import {changeStatuses} from '../../../utils/change-util';
+import {isServiceUser} from '../../../utils/account-util';
+import {customElement, property} from '@polymer/decorators';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {
+  ChangeInfo,
+  ServerInfo,
+  AccountInfo,
+  QuickLabelInfo,
+  Timestamp,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+enum ChangeSize {
+  XS = 10,
+  SMALL = 50,
+  MEDIUM = 250,
+  LARGE = 1000,
+}
+
+// export for testing
+export enum LabelCategory {
+  NOT_APPLICABLE = 'NOT_APPLICABLE',
+  APPROVED = 'APPROVED',
+  POSITIVE = 'POSITIVE',
+  NEUTRAL = 'NEUTRAL',
+  UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS',
+  NEGATIVE = 'NEGATIVE',
+  REJECTED = 'REJECTED',
+}
+
+export interface ChangeListToggleReviewedDetail {
+  change: ChangeInfo;
+  reviewed: boolean;
+}
+
+// How many reviewers should be shown with an account-label?
+const PRIMARY_REVIEWERS_COUNT = 2;
+
+@customElement('gr-change-list-item')
+export class GrChangeListItem extends ChangeTableMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /** The logged-in user's account, or null if no user is logged in. */
+  @property({type: Object})
+  account: AccountInfo | null = null;
+
+  @property({type: Array})
+  visibleChangeTableColumns?: string[];
+
+  @property({type: Array})
+  labelNames?: string[];
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  /** Name of the section in the change-list. Used for reporting. */
+  @property({type: String})
+  sectionName?: string;
+
+  @property({type: String, computed: '_computeChangeURL(change)'})
+  changeURL?: string;
+
+  @property({type: Array, computed: '_changeStatuses(change)'})
+  statuses?: string[];
+
+  @property({type: Boolean})
+  showStar = false;
+
+  @property({type: Boolean})
+  showNumber = false;
+
+  @property({type: String, computed: '_computeChangeSize(change)'})
+  _changeSize?: string;
+
+  @property({type: Array})
+  _dynamicCellEndpoints?: string[];
+
+  reporting: ReportingService = appContext.reportingService;
+
+  /** @override */
+  attached() {
+    super.attached();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-list-item-cell'
+        );
+      });
+  }
+
+  _changeStatuses(change?: ChangeInfo) {
+    if (!change) return [];
+    return changeStatuses(change);
+  }
+
+  _computeChangeURL(change?: ChangeInfo) {
+    if (!change) return '';
+    return GerritNav.getUrlForChange(change);
+  }
+
+  _computeLabelTitle(change: ChangeInfo | undefined, labelName: string) {
+    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+    const category = this._computeLabelCategory(change, labelName);
+    if (!label || category === LabelCategory.NOT_APPLICABLE) {
+      return 'Label not applicable';
+    }
+    if (category === LabelCategory.UNRESOLVED_COMMENTS) {
+      const num = change?.unresolved_comment_count ?? 0;
+      const plural = num > 1 ? 's' : '';
+      return `${num} unresolved comment${plural}`;
+    }
+    const significantLabel =
+      label.rejected || label.approved || label.disliked || label.recommended;
+    if (significantLabel && significantLabel.name) {
+      return `${labelName}\nby ${significantLabel.name}`;
+    }
+    return labelName;
+  }
+
+  _computeLabelClass(change: ChangeInfo | undefined, labelName: string) {
+    const category = this._computeLabelCategory(change, labelName);
+    const classes = ['cell', 'label'];
+    switch (category) {
+      case LabelCategory.NOT_APPLICABLE:
+        classes.push('u-gray-background');
+        break;
+      case LabelCategory.APPROVED:
+        classes.push('u-green');
+        break;
+      case LabelCategory.POSITIVE:
+        classes.push('u-monospace');
+        classes.push('u-green');
+        break;
+      case LabelCategory.NEGATIVE:
+        classes.push('u-monospace');
+        classes.push('u-red');
+        break;
+      case LabelCategory.REJECTED:
+        classes.push('u-red');
+        break;
+    }
+    return classes.sort().join(' ');
+  }
+
+  _computeHasLabelIcon(change: ChangeInfo | undefined, labelName: string) {
+    return this._computeLabelIcon(change, labelName) !== '';
+  }
+
+  _computeLabelIcon(change: ChangeInfo | undefined, labelName: string): string {
+    const category = this._computeLabelCategory(change, labelName);
+    switch (category) {
+      case LabelCategory.APPROVED:
+        return 'gr-icons:check';
+      case LabelCategory.UNRESOLVED_COMMENTS:
+        return 'gr-icons:comment';
+      case LabelCategory.REJECTED:
+        return 'gr-icons:close';
+      default:
+        return '';
+    }
+  }
+
+  _computeLabelCategory(change: ChangeInfo | undefined, labelName: string) {
+    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+    if (!label) {
+      return LabelCategory.NOT_APPLICABLE;
+    }
+    if (label.rejected) {
+      return LabelCategory.REJECTED;
+    }
+    if (label.value && label.value < 0) {
+      return LabelCategory.NEGATIVE;
+    }
+    if (change?.unresolved_comment_count && labelName === 'Code-Review') {
+      return LabelCategory.UNRESOLVED_COMMENTS;
+    }
+    if (label.approved) {
+      return LabelCategory.APPROVED;
+    }
+    if (label.value && label.value > 0) {
+      return LabelCategory.POSITIVE;
+    }
+    return LabelCategory.NEUTRAL;
+  }
+
+  _computeLabelValue(change: ChangeInfo | undefined, labelName: string) {
+    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+    const category = this._computeLabelCategory(change, labelName);
+    switch (category) {
+      case LabelCategory.NOT_APPLICABLE:
+        return '';
+      case LabelCategory.APPROVED:
+        return '\u2713'; // ✓
+      case LabelCategory.POSITIVE:
+        return `+${label?.value}`;
+      case LabelCategory.NEUTRAL:
+        return '';
+      case LabelCategory.UNRESOLVED_COMMENTS:
+        return 'u';
+      case LabelCategory.NEGATIVE:
+        return `${label?.value}`;
+      case LabelCategory.REJECTED:
+        return '\u2715'; // ✕
+    }
+  }
+
+  _computeRepoUrl(change?: ChangeInfo) {
+    if (!change) return '';
+    return GerritNav.getUrlForProjectChanges(
+      change.project,
+      true,
+      change.internalHost
+    );
+  }
+
+  _computeRepoBranchURL(change?: ChangeInfo) {
+    if (!change) return '';
+    return GerritNav.getUrlForBranch(
+      change.branch,
+      change.project,
+      undefined,
+      change.internalHost
+    );
+  }
+
+  _computeTopicURL(change?: ChangeInfo) {
+    if (!change?.topic) {
+      return '';
+    }
+    return GerritNav.getUrlForTopic(change.topic, 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 truncate whether or not the project name should be
+   * truncated. If this value is truthy, the name will be truncated.
+   */
+  _computeRepoDisplay(change: ChangeInfo | undefined, truncate: boolean) {
+    if (!change?.project) {
+      return '';
+    }
+    let str = '';
+    if (change.internalHost) {
+      str += change.internalHost + '/';
+    }
+    str += truncate ? truncatePath(change.project, 2) : change.project;
+    return str;
+  }
+
+  _computeSizeTooltip(change?: ChangeInfo) {
+    if (
+      !change ||
+      change.insertions + change.deletions === 0 ||
+      isNaN(change.insertions + change.deletions)
+    ) {
+      return 'Size unknown';
+    } else {
+      return `added ${change.insertions}, removed ${change.deletions} lines`;
+    }
+  }
+
+  _hasAttention(account: AccountInfo) {
+    if (!this.change || !this.change.attention_set || !account._account_id) {
+      return false;
+    }
+    return hasOwnProperty(this.change.attention_set, account._account_id);
+  }
+
+  /**
+   * Computes the array of all reviewers with sorting the reviewers in the
+   * attention set before others, and the current user first.
+   */
+  _computeReviewers(change?: ChangeInfo) {
+    if (!change?.reviewers || !change?.reviewers.REVIEWER) return [];
+    const reviewers = [...change.reviewers.REVIEWER].filter(
+      r =>
+        (!change.owner || change.owner._account_id !== r._account_id) &&
+        !isServiceUser(r)
+    );
+    reviewers.sort((r1, r2) => {
+      if (this.account) {
+        if (r1._account_id === this.account._account_id) return -1;
+        if (r2._account_id === this.account._account_id) return 1;
+      }
+      if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
+      if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
+      return (r1.name || '').localeCompare(r2.name || '');
+    });
+    return reviewers;
+  }
+
+  _computePrimaryReviewers(change?: ChangeInfo) {
+    return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
+  }
+
+  _computeAdditionalReviewers(change?: ChangeInfo) {
+    return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
+  }
+
+  _computeAdditionalReviewersCount(change?: ChangeInfo) {
+    return this._computeAdditionalReviewers(change).length;
+  }
+
+  _computeAdditionalReviewersTitle(
+    change: ChangeInfo | undefined,
+    config: ServerInfo
+  ) {
+    if (!change || !config) return '';
+    return this._computeAdditionalReviewers(change)
+      .map(user => getDisplayName(config, user, true))
+      .join(', ');
+  }
+
+  _computeComments(unresolved_comment_count?: number) {
+    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?: ChangeInfo) {
+    if (!change) return null;
+    const delta = change.insertions + change.deletions;
+    if (isNaN(delta) || delta === 0) {
+      return null; // Unknown
+    }
+    if (delta < ChangeSize.XS) {
+      return 'XS';
+    } else if (delta < ChangeSize.SMALL) {
+      return 'S';
+    } else if (delta < ChangeSize.MEDIUM) {
+      return 'M';
+    } else if (delta < ChangeSize.LARGE) {
+      return 'L';
+    } else {
+      return 'XL';
+    }
+  }
+
+  _computeWaiting(
+    account?: AccountInfo,
+    change?: ChangeInfo
+  ): Timestamp | undefined {
+    if (!account?._account_id || !change?.attention_set) return undefined;
+    return change?.attention_set[account._account_id]?.last_update;
+  }
+
+  toggleReviewed() {
+    if (!this.change) return;
+    const newVal = !this.change?.reviewed;
+    this.set('change.reviewed', newVal);
+    const detail: ChangeListToggleReviewedDetail = {
+      change: this.change,
+      reviewed: newVal,
+    };
+    this.dispatchEvent(
+      new CustomEvent('toggle-reviewed', {
+        bubbles: true,
+        composed: true,
+        detail,
+      })
+    );
+  }
+
+  _handleChangeClick() {
+    // Don't prevent the default and neither stop bubbling. We just want to
+    // report the click, but then let the browser handle the click on the link.
+
+    const selfId = (this.account && this.account._account_id) || -1;
+    const ownerId =
+      (this.change && this.change.owner && this.change.owner._account_id) || -1;
+
+    this.reporting.reportInteraction('change-row-clicked', {
+      section: this.sectionName,
+      isOwner: selfId === ownerId,
+    });
+  }
+
+  _computeCommaHidden(index?: number, change?: ChangeInfo) {
+    if (index === undefined) return false;
+    if (change === undefined) return false;
+
+    const additionalCount = this._computeAdditionalReviewersCount(change);
+    const primaryCount = this._computePrimaryReviewers(change).length;
+    const isLast = index === primaryCount - 1;
+    return isLast && additionalCount === 0;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-item': 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
deleted file mode 100644
index 13b3c24..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
+++ /dev/null
@@ -1,278 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
new file mode 100644
index 0000000..fdb4534
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -0,0 +1,325 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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);
+    }
+    .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;
+    }
+    .reviewers {
+      --account-max-length: 90px;
+    }
+    .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,
+    .u-green iron-icon {
+      color: var(--positive-green-text-color);
+    }
+    .u-red,
+    .u-red iron-icon {
+      color: var(--negative-red-text-color);
+    }
+    .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);
+    }
+    .cell.label iron-icon {
+      vertical-align: top;
+    }
+    @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 aria-hidden="true" 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]]"
+          on-click="_handleChangeClick"
+        >
+          [[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
+      highlight-attention
+      change="[[change]]"
+      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="[[_computePrimaryReviewers(change)]]"
+        as="reviewer"
+        indexAs="index"
+      >
+        <gr-account-link
+          hide-avatar=""
+          hide-status=""
+          first-name
+          highlight-attention
+          change="[[change]]"
+          account="[[reviewer]]"
+        ></gr-account-link
+        ><span
+          hidden$="[[_computeCommaHidden(index, change)]]"
+          aria-hidden="true"
+          >,
+        </span>
+      </template>
+      <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
+        <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
+          +[[_computeAdditionalReviewersCount(change, config)]]
+        </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 submitted"
+    hidden$="[[isColumnHidden('Submitted', visibleChangeTableColumns)]]"
+  >
+    <gr-date-formatter
+      has-tooltip=""
+      date-str="[[change.submitted]]"
+    ></gr-date-formatter>
+  </td>
+  <td
+    class="cell waiting"
+    hidden$="[[isColumnHidden('Waiting', visibleChangeTableColumns)]]"
+  >
+    <gr-date-formatter
+      has-tooltip=""
+      force-relative=""
+      relative-option-no-ago=""
+      date-str="[[_computeWaiting(account, change)]]"
+    ></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)]]"
+    >
+      <template is="dom-if" if="[[_computeHasLabelIcon(change, labelName)]]">
+        <iron-icon icon="[[_computeLabelIcon(change, labelName)]]"></iron-icon>
+      </template>
+      <template is="dom-if" if="[[!_computeHasLabelIcon(change, labelName)]]">
+        <span>[[_computeLabelValue(change, labelName)]]</span>
+      </template>
+    </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
deleted file mode 100644
index 6b45618..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ /dev/null
@@ -1,281 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-change-list-item</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-change-list-item></gr-change-list-item>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-change-list-item.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-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); },
-    });
-    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-item/gr-change-list-item_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
new file mode 100644
index 0000000..d3274f3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
@@ -0,0 +1,358 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-list-item.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {LabelCategory} from './gr-change-list-item.js';
+
+const basicFixture = fixtureFromElement('gr-change-list-item');
+
+suite('gr-change-list-item tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeLabelCategory', () => {
+    assert.equal(element._computeLabelCategory({labels: {}}),
+        LabelCategory.NOT_APPLICABLE);
+    assert.equal(element._computeLabelCategory(
+        {labels: {}}, 'Verified'), LabelCategory.NOT_APPLICABLE);
+    assert.equal(element._computeLabelCategory(
+        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
+    LabelCategory.APPROVED);
+    assert.equal(element._computeLabelCategory(
+        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
+    LabelCategory.REJECTED);
+    assert.equal(element._computeLabelCategory(
+        {
+          labels: {'Code-Review': {approved: true, value: 1}},
+          unresolved_comment_count: 1,
+        }, 'Code-Review'),
+    LabelCategory.UNRESOLVED_COMMENTS);
+    assert.equal(element._computeLabelCategory(
+        {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
+    LabelCategory.POSITIVE);
+    assert.equal(element._computeLabelCategory(
+        {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
+    LabelCategory.NEGATIVE);
+    assert.equal(element._computeLabelCategory(
+        {labels: {'Code-Review': {value: -1}}}, 'Verified'),
+    LabelCategory.NOT_APPLICABLE);
+  });
+
+  test('_computeLabelClass', () => {
+    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');
+    assert.equal(element._computeLabelClass(
+        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
+    'cell label 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');
+  });
+
+  test('_computeLabelTitle', () => {
+    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._computeLabelTitle(
+        {
+          labels: {'Code-Review': {approved: true, value: 1}},
+          unresolved_comment_count: 1,
+        }, 'Code-Review'),
+    '1 unresolved comment');
+    assert.equal(element._computeLabelTitle(
+        {
+          labels: {'Code-Review': {approved: true, value: 1}},
+          unresolved_comment_count: 2,
+        }, 'Code-Review'),
+    '2 unresolved comments');
+  });
+
+  test('_computeLabelIcon', () => {
+    assert.equal(element._computeLabelIcon({labels: {}}), '');
+    assert.equal(element._computeLabelIcon(
+        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
+    'gr-icons:check');
+    assert.equal(element._computeLabelIcon(
+        {
+          labels: {'Code-Review': {approved: true, value: 1}},
+          unresolved_comment_count: 1,
+        }, 'Code-Review'),
+    'gr-icons:comment');
+  });
+
+  test('_computeLabelValue', () => {
+    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',
+    ];
+
+    flush();
+
+    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',
+    ];
+
+    flush();
+
+    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);
+      }
+    }
+  });
+
+  function checkComputeReviewers(
+      userId, reviewerIds, reviewerNames, attSetIds, expected) {
+    element.account = userId ? {_account_id: userId} : null;
+    element.change = {
+      owner: {
+        _account_id: 99,
+      },
+      reviewers: {
+        REVIEWER: [],
+      },
+      attention_set: {},
+    };
+    for (let i = 0; i < reviewerIds.length; i++) {
+      element.change.reviewers.REVIEWER.push({
+        _account_id: reviewerIds[i],
+        name: reviewerNames[i],
+      });
+    }
+    attSetIds.forEach(id => element.change.attention_set[id] = {});
+
+    const actual = element._computeReviewers(element.change)
+        .map(r => r._account_id);
+    assert.deepEqual(actual, expected);
+  }
+
+  test('compute reviewers', () => {
+    checkComputeReviewers(null, [], [], [], []);
+    checkComputeReviewers(1, [], [], [], []);
+    checkComputeReviewers(1, [2], ['a'], [], [2]);
+    checkComputeReviewers(1, [2, 3], [undefined, 'a'], [], [2, 3]);
+    checkComputeReviewers(1, [2, 3], ['a', undefined], [], [3, 2]);
+    checkComputeReviewers(1, [99], ['owner'], [], []);
+    checkComputeReviewers(
+        1, [2, 3, 4, 5], ['b', 'a', 'd', 'c'], [3, 4], [3, 4, 2, 5]);
+    checkComputeReviewers(
+        1, [2, 3, 1, 4, 5], ['b', 'a', 'x', 'd', 'c'], [3, 4], [1, 3, 4, 2, 5]);
+  });
+
+  test('random column does not exist', () => {
+    element.visibleChangeTableColumns = [
+      'Bad',
+    ];
+
+    flush();
+    const elementClass = '.bad';
+    assert.isNotOk(element.shadowRoot
+        .querySelector(elementClass));
+  });
+
+  test('assignee only displayed if there is one', () => {
+    element.change = {};
+    flush();
+    assert.isNotOk(element.shadowRoot
+        .querySelector('.assignee gr-account-link'));
+    assert.equal(element.shadowRoot
+        .querySelector('.assignee').textContent.trim(), '--');
+    element.change = {
+      assignee: {
+        name: 'test',
+        status: 'test',
+      },
+    };
+    flush();
+    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,
+    }), 'added 1, removed 2 lines');
+  });
+
+  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', () => {
+    sinon.stub(GerritNav);
+    const change = {
+      internalHost: 'test-host',
+      project: 'test-repo',
+      topic: 'test-topic',
+      branch: 'test-branch',
+    };
+    element.change = change;
+    flush();
+
+    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, undefined, 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');
+  });
+});
+
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
deleted file mode 100644
index c416b11..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ /dev/null
@@ -1,310 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../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 LookupQueryPatterns = {
-  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
-  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-};
-
-const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
-
-const REPO_QUERY_PATTERN =
-    /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
-
-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 {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-
-      /**
-       * True when user is logged in.
-       */
-      _loggedIn: {
-        type: Boolean,
-        computed: '_computeLoggedIn(account)',
-      },
-
-      account: {
-        type: Object,
-        value: null,
-      },
-
-      /**
-       * State persisted across restamps of the element.
-       *
-       * Need sub-property declaration since it is used in template before
-       * assignment.
-       *
-       * @type {{ selectedChangeIndex: (number|undefined) }}
-       *
-       */
-      viewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-      },
-
-      preferences: Object,
-
-      _changesPerPage: Number,
-
-      /**
-       * Currently active query.
-       */
-      _query: {
-        type: String,
-        value: '',
-      },
-
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-
-      /**
-       * Change objects loaded from the server.
-       */
-      _changes: {
-        type: Array,
-        observer: '_changesChanged',
-      },
-
-      /**
-       * For showing a "loading..." string during ajax requests.
-       */
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-
-      /** @type {?string} */
-      _userId: {
-        type: String,
-        value: null,
-      },
-
-      /** @type {?string} */
-      _repo: {
-        type: String,
-        value: null,
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('next-page',
-        () => this._handleNextPage());
-    this.addEventListener('previous-page',
-        () => this._handlePreviousPage());
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadPreferences();
-  }
-
-  _paramsChanged(value) {
-    if (value.view !== 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);
-    }
-
-    // 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])) {
-                GerritNav.navigateToChange(changes[0]);
-                return;
-              }
-            }
-          }
-          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;
-    }
-    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 GerritNav.getUrlForSearchQuery(query, newOffset);
-  }
-
-  _computePrevArrowClass(offset) {
-    return offset === 0 ? 'hide' : '';
-  }
-
-  _computeNextArrowClass(changes) {
-    const more = changes.length && changes[changes.length - 1]._more_changes;
-    return more ? '' : 'hide';
-  }
-
-  _computeNavClass(loading) {
-    return loading || !this._changes || !this._changes.length ? 'hide' : '';
-  }
-
-  _handleNextPage() {
-    if (this.$.nextArrow.hidden) { return; }
-    page.show(this._computeNavLink(
-        this._query, this._offset, 1, this._changesPerPage));
-  }
-
-  _handlePreviousPage() {
-    if (this.$.prevArrow.hidden) { return; }
-    page.show(this._computeNavLink(
-        this._query, this._offset, -1, this._changesPerPage));
-  }
-
-  _changesChanged(changes) {
-    this._userId = null;
-    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 (REPO_QUERY_PATTERN.test(this._query)) {
-      this._repo = changes[0].project;
-    }
-  }
-
-  _computeHeaderClass(id) {
-    return id ? '' : 'hide';
-  }
-
-  _computePage(offset, changesPerPage) {
-    return offset / changesPerPage + 1;
-  }
-
-  _computeLoggedIn(account) {
-    return !!(account && Object.keys(account).length > 0);
-  }
-
-  _handleToggleStar(e) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number,
-        e.detail.starred);
-  }
-
-  _handleToggleReviewed(e) {
-    this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-        e.detail.reviewed);
-  }
-}
-
-customElements.define(GrChangeListView.is, GrChangeListView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
new file mode 100644
index 0000000..927d32f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -0,0 +1,307 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list/gr-change-list';
+import '../gr-repo-header/gr-repo-header';
+import '../gr-user-header/gr-user-header';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-list-view_html';
+import {page} from '../../../utils/page-wrapper-utils';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {
+  AccountDetailInfo,
+  AccountId,
+  ChangeInfo,
+  EmailAddress,
+  PreferencesInput,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {ChangeListViewState} from '../../../types/types';
+
+const LookupQueryPatterns = {
+  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
+  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
+  COMMIT: /[0-9a-f]{40}/,
+};
+
+const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
+
+const REPO_QUERY_PATTERN = /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+
+const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
+
+export interface GrChangeListView {
+  $: {
+    restAPI: RestApiService & Element;
+    prevArrow: HTMLAnchorElement;
+    nextArrow: HTMLAnchorElement;
+  };
+}
+
+@customElement('gr-change-list-view')
+export class GrChangeListView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementParams;
+
+  @property({type: Boolean, computed: '_computeLoggedIn(account)'})
+  _loggedIn?: boolean;
+
+  @property({type: Object})
+  account: AccountDetailInfo | null = null;
+
+  @property({type: Object, notify: true})
+  viewState: ChangeListViewState = {};
+
+  @property({type: Object})
+  preferences?: PreferencesInput;
+
+  @property({type: Number})
+  _changesPerPage?: number;
+
+  @property({type: String})
+  _query = '';
+
+  @property({type: Number})
+  _offset?: number;
+
+  @property({type: Array, observer: '_changesChanged'})
+  _changes?: ChangeInfo[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _userId: AccountId | EmailAddress | null = null;
+
+  @property({type: String})
+  _repo: string | null = null;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('next-page', () => this._handleNextPage());
+    this.addEventListener('previous-page', () => this._handlePreviousPage());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+  }
+
+  _paramsChanged(value: AppElementParams) {
+    if (value.view !== GerritView.SEARCH) return;
+
+    this._loading = true;
+    this._query = value.query;
+    const offset = Number(value.offset);
+    this._offset = isNaN(offset) ? 0 : offset;
+    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.dispatchEvent(
+        new CustomEvent('title-change', {
+          detail: {title: this._query},
+          composed: true,
+          bubbles: true,
+        })
+      )
+    );
+
+    this.$.restAPI
+      .getPreferences()
+      .then(prefs => {
+        if (!prefs) {
+          throw new Error('getPreferences returned undefined');
+        }
+        this._changesPerPage = prefs.changes_per_page;
+        return this._getChanges();
+      })
+      .then(changes => {
+        changes = changes || [];
+        if (this._query && changes.length === 1) {
+          let query: keyof typeof LookupQueryPatterns;
+          for (query in LookupQueryPatterns) {
+            if (
+              hasOwnProperty(LookupQueryPatterns, query) &&
+              this._query.match(LookupQueryPatterns[query])
+            ) {
+              // "Back"/"Forward" buttons work correctly only with
+              // opt_redirect options
+              GerritNav.navigateToChange(
+                changes[0],
+                undefined,
+                undefined,
+                undefined,
+                true
+              );
+              return;
+            }
+          }
+        }
+        this._changes = changes;
+        this._loading = false;
+      });
+  }
+
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this.$.restAPI.getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
+      }
+    });
+  }
+
+  _getChanges() {
+    return this.$.restAPI.getChanges(
+      this._changesPerPage,
+      this._query,
+      this._offset
+    );
+  }
+
+  _limitFor(query: string, defaultLimit: number) {
+    const match = query.match(LIMIT_OPERATOR_PATTERN);
+    if (!match) {
+      return defaultLimit;
+    }
+    return Number(match[1]);
+  }
+
+  _computeNavLink(
+    query: string,
+    offset: number | undefined,
+    direction: number,
+    changesPerPage: number
+  ) {
+    offset = offset ?? 0;
+    const limit = this._limitFor(query, changesPerPage);
+    const newOffset = Math.max(0, offset + limit * direction);
+    return GerritNav.getUrlForSearchQuery(query, newOffset);
+  }
+
+  _computePrevArrowClass(offset?: number) {
+    return offset === 0 ? 'hide' : '';
+  }
+
+  _computeNextArrowClass(changes?: ChangeInfo[]) {
+    const more = changes?.length && changes[changes.length - 1]._more_changes;
+    return more ? '' : 'hide';
+  }
+
+  _computeNavClass(loading?: boolean) {
+    return loading || !this._changes || !this._changes.length ? 'hide' : '';
+  }
+
+  _handleNextPage() {
+    if (this.$.nextArrow.hidden || !this._changesPerPage) return;
+    page.show(
+      this._computeNavLink(this._query, this._offset, 1, this._changesPerPage)
+    );
+  }
+
+  _handlePreviousPage() {
+    if (this.$.prevArrow.hidden || !this._changesPerPage) return;
+    page.show(
+      this._computeNavLink(this._query, this._offset, -1, this._changesPerPage)
+    );
+  }
+
+  _changesChanged(changes?: ChangeInfo[]) {
+    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 (REPO_QUERY_PATTERN.test(this._query)) {
+      this._repo = changes[0].project;
+    }
+  }
+
+  _computeHeaderClass(id?: string) {
+    return id ? '' : 'hide';
+  }
+
+  _computePage(offset?: number, changesPerPage?: number) {
+    if (offset === undefined || changesPerPage === undefined) return;
+    return offset / changesPerPage + 1;
+  }
+
+  _computeLoggedIn(account?: AccountDetailInfo) {
+    return !!(account && Object.keys(account).length > 0);
+  }
+
+  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+  }
+
+  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+    this.$.restAPI.saveChangeReviewed(
+      e.detail.change._number,
+      e.detail.reviewed
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-view': 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
deleted file mode 100644
index 4add1da..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
new file mode 100644
index 0000000..0e8f843
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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" aria-label="Older"> </iron-icon>
+      </a>
+      <a
+        id="nextArrow"
+        href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
+        class$="[[_computeNextArrowClass(_changes)]]"
+      >
+        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
+        </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
deleted file mode 100644
index 58ec4e1..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ /dev/null
@@ -1,269 +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">
-<meta charset="utf-8">
-<title>gr-change-list-view</title>
-
-<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>
-    <gr-change-list-view></gr-change-list-view>
-  </template>
-</test-fixture>
-
-<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';
-
-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(() => {
-    });
-
-    teardown(done => {
-      flush(() => {
-        sandbox.restore();
-        done();
-      });
-    });
-
-    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('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('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('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);
-    });
-
-    test('Change ID with multiple search results 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);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
new file mode 100644
index 0000000..af3acd8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
@@ -0,0 +1,259 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-list-view.js';
+import {page} from '../../../utils/page-wrapper-utils.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import 'lodash/lodash.js';
+
+const basicFixture = fixtureFromElement('gr-change-list-view');
+
+const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+const COMMIT_HASH = '12345678';
+
+suite('gr-change-list-view tests', () => {
+  let element;
+
+  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 = basicFixture.instantiate();
+  });
+
+  teardown(done => {
+    flush(() => {
+      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 = sinon.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 = sinon.stub(page, 'show');
+    element._changesPerPage = 10;
+    element.$.nextArrow.hidden = true;
+    element._handleNextPage();
+    assert.isFalse(showStub.called);
+    element.$.nextArrow.hidden = false;
+    element._handleNextPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('_handlePreviousPage', () => {
+    const showStub = sinon.stub(page, 'show');
+    element._changesPerPage = 10;
+    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(() => {
+    });
+
+    teardown(done => {
+      flush(() => {
+        sinon.restore();
+        done();
+      });
+    });
+
+    test('Searching for a change ID redirects to change', done => {
+      const change = {_number: 1};
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sinon.stub(GerritNav, 'navigateToChange').callsFake(
+          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
+            assert.equal(url, change);
+            assert.isTrue(opt_redirect);
+            done();
+          });
+
+      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
+    });
+
+    test('Searching for a change num redirects to change', done => {
+      const change = {_number: 1};
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sinon.stub(GerritNav, 'navigateToChange').callsFake(
+          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
+            assert.equal(url, change);
+            assert.isTrue(opt_redirect);
+            done();
+          });
+
+      element.params = {view: GerritNav.View.SEARCH, query: '1'};
+    });
+
+    test('Commit hash redirects to change', done => {
+      const change = {_number: 1};
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sinon.stub(GerritNav, 'navigateToChange').callsFake(
+          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
+            assert.equal(url, change);
+            assert.isTrue(opt_redirect);
+            done();
+          });
+
+      element.params = {view: GerritNav.View.SEARCH, query: COMMIT_HASH};
+    });
+
+    test('Searching for an invalid change ID searches', () => {
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([]));
+      const stub = sinon.stub(GerritNav, 'navigateToChange');
+
+      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
+      flush();
+
+      assert.isFalse(stub.called);
+    });
+
+    test('Change ID with multiple search results searches', () => {
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([{}, {}]));
+      const stub = sinon.stub(GerritNav, 'navigateToChange');
+
+      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
+      flush();
+
+      assert.isFalse(stub.called);
+    });
+  });
+});
+
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
deleted file mode 100644
index 0a19ae1..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ /dev/null
@@ -1,443 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../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';
-
-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 {
-    /**
-     * The logged-in user's account, or an empty object if no user is logged
-     * in.
-     */
-      account: {
-        type: Object,
-        value: null,
-      },
-      /**
-       * An array of ChangeInfo objects to render.
-       * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
-       */
-      changes: {
-        type: Array,
-        observer: '_changesChanged',
-      },
-      /**
-       * ChangeInfo objects grouped into arrays. The sections and changes
-       * properties should not be used together.
-       *
-       * @type {!Array<{
-       *   name: string,
-       *   query: string,
-       *   results: !Array<!Object>
-       * }>}
-       */
-      sections: {
-        type: Array,
-        value() { return []; },
-      },
-      labelNames: {
-        type: Array,
-        computed: '_computeLabelNames(sections)',
-      },
-      _dynamicHeaderEndpoints: {
-        type: Array,
-      },
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
-      showNumber: Boolean, // No default value to prevent flickering.
-      showStar: {
-        type: Boolean,
-        value: false,
-      },
-      showReviewedState: {
-        type: Boolean,
-        value: false,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      changeTableColumns: Array,
-      visibleChangeTableColumns: Array,
-      preferences: Object,
-      _config: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_sectionsChanged(sections.*)',
-      '_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',
-    };
-  }
-
-  constructor() {
-    super();
-    this.flagsService = appContext.flagsService;
-  }
-
-  /** @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);
-      }
-    }
-  }
-
-  _computeColspan(changeTableColumns, labelNames) {
-    if (!changeTableColumns || !labelNames) return;
-    return changeTableColumns.length + labelNames.length +
-        NUMBER_FIXED_COLUMNS;
-  }
-
-  _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();
-  }
-
-  _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);
-  }
-
-  _changesChanged(changes) {
-    this.sections = changes ? [{results: changes}] : [];
-  }
-
-  _processQuery(query) {
-    let tokens = query.split(' ');
-    const invalidTokens = ['limit:', 'age:', '-age:'];
-    tokens = tokens.filter(token => !invalidTokens
-        .some(invalidToken => token.startsWith(invalidToken)));
-    return tokens.join(' ');
-  }
-
-  _sectionHref(query) {
-    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
-  }
-
-  /**
-   * 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;
-  }
-
-  _computeItemSelected(sectionIndex, index, selectedIndex) {
-    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
-    return idx == selectedIndex;
-  }
-
-  _computeItemNeedsReview(account, change, showReviewedState) {
-    return showReviewedState && !change.reviewed &&
-        !change.work_in_progress &&
-        this.changeIsOpen(change) &&
-        (!account || account._account_id != change.owner._account_id);
-  }
-
-  _computeItemHighlight(account, change) {
-    // Do not show the assignee highlight if the change is not open.
-    if (!change ||!change.assignee ||
-        !account ||
-        CLOSED_STATUS.indexOf(change.status) !== -1) {
-      return false;
-    }
-    return account._account_id === change.assignee._account_id;
-  }
-
-  _nextChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.next();
-  }
-
-  _prevChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.previous();
-  }
-
-  _openChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    GerritNav.navigateToChange(this._changeForIndex(this.selectedIndex));
-  }
-
-  _nextPage(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-      return;
-    }
-
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('next-page', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _prevPage(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-      return;
-    }
-
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('previous-page', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _toggleChangeReviewed(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._toggleReviewedForIndex(this.selectedIndex);
-  }
-
-  _toggleReviewedForIndex(index) {
-    const changeEls = this._getListItems();
-    if (index >= changeEls.length || !changeEls[index]) {
-      return;
-    }
-
-    const changeEl = changeEls[index];
-    changeEl.toggleReviewed();
-  }
-
-  _refreshChangeList(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this._reloadWindow();
-  }
-
-  _reloadWindow() {
-    window.location.reload();
-  }
-
-  _toggleChangeStar(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._toggleStarForIndex(this.selectedIndex);
-  }
-
-  _toggleStarForIndex(index) {
-    const changeEls = this._getListItems();
-    if (index >= changeEls.length || !changeEls[index]) {
-      return;
-    }
-
-    const changeEl = changeEls[index];
-    changeEl.shadowRoot
-        .querySelector('gr-change-star').toggleStar();
-  }
-
-  _changeForIndex(index) {
-    const changeEls = this._getListItems();
-    if (index < changeEls.length && changeEls[index]) {
-      return changeEls[index].change;
-    }
-    return null;
-  }
-
-  _getListItems() {
-    return Array.from(
-        dom(this.root).querySelectorAll('gr-change-list-item'));
-  }
-
-  _sectionsChanged() {
-    // Flush DOM operations so that the list item elements will be loaded.
-    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.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
new file mode 100644
index 0000000..3acdaf9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -0,0 +1,542 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../styles/gr-change-list-styles';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list-item/gr-change-list-item';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-list_html';
+import {appContext} from '../../../services/app-context';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  Modifier,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+  GerritNav,
+  DashboardSection,
+  YOUR_TURN,
+  CLOSED,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {changeIsOpen, isOwner} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ServerInfo,
+  PreferencesInput,
+} from '../../../types/common';
+import {
+  hasAttention,
+  isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+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;
+
+export interface ChangeListSection {
+  name?: string;
+  query?: string;
+  results: ChangeInfo[];
+}
+export interface GrChangeList {
+  $: {
+    restAPI: RestApiService & Element;
+    cursor: GrCursorManager;
+  };
+}
+@customElement('gr-change-list')
+export class GrChangeList extends ChangeTableMixin(
+  KeyboardShortcutMixin(
+    GestureEventListeners(LegacyElementMixin(PolymerElement))
+  )
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when next page key shortcut was pressed.
+   *
+   * @event next-page
+   */
+
+  /**
+   * Fired when previous page key shortcut was pressed.
+   *
+   * @event previous-page
+   */
+
+  /**
+   * The logged-in user's account, or an empty object if no user is logged
+   * in.
+   */
+  @property({type: Object})
+  account: AccountInfo | undefined = undefined;
+
+  @property({type: Array, observer: '_changesChanged'})
+  changes?: ChangeInfo[];
+
+  /**
+   * ChangeInfo objects grouped into arrays. The sections and changes
+   * properties should not be used together.
+   */
+  @property({type: Array})
+  sections: ChangeListSection[] = [];
+
+  @property({type: Array, computed: '_computeLabelNames(sections)'})
+  labelNames?: string[];
+
+  @property({type: Array})
+  _dynamicHeaderEndpoints?: string[];
+
+  @property({type: Number, notify: true})
+  selectedIndex?: number;
+
+  @property({type: Boolean})
+  showNumber?: boolean; // No default value to prevent flickering.
+
+  @property({type: Boolean})
+  showStar = false;
+
+  @property({type: Boolean})
+  showReviewedState = false;
+
+  @property({type: Object})
+  keyEventTarget: HTMLElement = document.body;
+
+  @property({type: Array})
+  changeTableColumns?: string[];
+
+  @property({type: Array})
+  visibleChangeTableColumns?: string[];
+
+  @property({type: Object})
+  preferences?: PreferencesInput;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  flagsService = appContext.flagsService;
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+      [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+      [Shortcut.NEXT_PAGE]: '_nextPage',
+      [Shortcut.PREV_PAGE]: '_prevPage',
+      [Shortcut.OPEN_CHANGE]: '_openChange',
+      [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+      [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+      [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicHeaderEndpoints = getPluginEndpoints().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: KeyboardEvent) {
+    if (e.keyCode === 13) {
+      // Enter.
+      this._openChange((e as unknown) as CustomKeyboardEvent);
+    }
+  }
+
+  _lowerCase(column: string) {
+    return column.toLowerCase();
+  }
+
+  @observe('account', 'preferences', '_config')
+  _computePreferences(
+    account?: AccountInfo,
+    preferences?: PreferencesInput,
+    config?: ServerInfo
+  ) {
+    if (!config) {
+      return;
+    }
+
+    this.changeTableColumns = this.columnNames;
+    this.showNumber = false;
+    this.visibleChangeTableColumns = this.getEnabledColumns(
+      this.columnNames,
+      config,
+      this.flagsService.enabledExperiments
+    );
+
+    if (account && preferences) {
+      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
+        );
+      }
+    }
+  }
+
+  /**
+   * This methods allows us to customize the columns per section.
+   *
+   * @param visibleColumns are the columns according to configs and user prefs
+   */
+  _computeColumns(
+    section?: ChangeListSection,
+    visibleColumns?: string[]
+  ): string[] {
+    if (!section || !visibleColumns) return [];
+    const cols = [...visibleColumns];
+    const updatedIndex = cols.indexOf('Updated');
+    if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
+      cols[updatedIndex] = 'Waiting';
+    }
+    if (section.name === CLOSED.name && updatedIndex !== -1) {
+      cols[updatedIndex] = 'Submitted';
+    }
+    return cols;
+  }
+
+  _computeColspan(
+    section?: ChangeListSection,
+    visibleColumns?: string[],
+    labelNames?: string[]
+  ) {
+    const cols = this._computeColumns(section, visibleColumns);
+    if (!cols || !labelNames) return 1;
+    return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
+  }
+
+  _computeLabelNames(sections: ChangeListSection[]) {
+    if (!sections) {
+      return [];
+    }
+    let labels: string[] = [];
+    const nonExistingLabel = function (item: string) {
+      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();
+  }
+
+  _computeLabelShortcut(labelName: string) {
+    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);
+  }
+
+  _changesChanged(changes: ChangeInfo[]) {
+    this.sections = changes ? [{results: changes}] : [];
+  }
+
+  _processQuery(query: string) {
+    let tokens = query.split(' ');
+    const invalidTokens = ['limit:', 'age:', '-age:'];
+    tokens = tokens.filter(
+      token =>
+        !invalidTokens.some(invalidToken => token.startsWith(invalidToken))
+    );
+    return tokens.join(' ');
+  }
+
+  _sectionHref(query: string) {
+    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
+  }
+
+  /**
+   * Maps an index local to a particular section to the absolute index
+   * across all the changes on the page.
+   *
+   * @param sectionIndex index of section
+   * @param localIndex index of row within section
+   * @return absolute index of row in the aggregate dashboard
+   */
+  _computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
+    let idx = 0;
+    for (let i = 0; i < sectionIndex; i++) {
+      idx += this.sections[i].results.length;
+    }
+    return idx + localIndex;
+  }
+
+  _computeItemSelected(
+    sectionIndex: number,
+    index: number,
+    selectedIndex: number
+  ) {
+    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
+    return idx === selectedIndex;
+  }
+
+  _computeTabIndex(sectionIndex: number, index: number, selectedIndex: number) {
+    return this._computeItemSelected(sectionIndex, index, selectedIndex)
+      ? 0
+      : undefined;
+  }
+
+  _computeItemNeedsReview(
+    account: AccountInfo | undefined,
+    change: ChangeInfo,
+    showReviewedState: boolean,
+    config?: ServerInfo
+  ) {
+    return (
+      !isAttentionSetEnabled(config) &&
+      showReviewedState &&
+      !change.reviewed &&
+      !change.work_in_progress &&
+      changeIsOpen(change) &&
+      (!account || account._account_id !== change.owner._account_id)
+    );
+  }
+
+  _computeItemHighlight(
+    account?: AccountInfo,
+    change?: ChangeInfo,
+    config?: ServerInfo,
+    sectionName?: string
+  ) {
+    if (!change || !account) return false;
+    if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
+    return isAttentionSetEnabled(config)
+      ? hasAttention(config, account, change) &&
+          !isOwner(change, account) &&
+          sectionName === YOUR_TURN.name
+      : account._account_id === change.assignee?._account_id;
+  }
+
+  _nextChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.cursor.next();
+  }
+
+  _prevChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.cursor.previous();
+  }
+
+  _openChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    const change = this._changeForIndex(this.selectedIndex);
+    if (change) GerritNav.navigateToChange(change);
+  }
+
+  _nextPage(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('next-page', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _prevPage(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('previous-page', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _toggleChangeReviewed(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleReviewedForIndex(this.selectedIndex);
+  }
+
+  _toggleReviewedForIndex(index?: number) {
+    const changeEls = this._getListItems();
+    if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
+
+    const changeEl = changeEls[index];
+    changeEl.toggleReviewed();
+  }
+
+  _refreshChangeList(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._reloadWindow();
+  }
+
+  _reloadWindow() {
+    window.location.reload();
+  }
+
+  _toggleChangeStar(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleStarForIndex(this.selectedIndex);
+  }
+
+  _toggleStarForIndex(index?: number) {
+    const changeEls = this._getListItems();
+    if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
+
+    const changeEl = changeEls[index];
+    const grChangeStar = changeEl?.shadowRoot?.querySelector('gr-change-star');
+    if (grChangeStar) grChangeStar.toggleStar();
+  }
+
+  _changeForIndex(index?: number) {
+    const changeEls = this._getListItems();
+    if (index !== undefined && index < changeEls.length && changeEls[index]) {
+      return changeEls[index].change;
+    }
+    return null;
+  }
+
+  _getListItems() {
+    const items = this.root?.querySelectorAll('gr-change-list-item');
+    return !items ? [] : Array.from(items);
+  }
+
+  @observe('sections.*')
+  _sectionsChanged() {
+    // Flush DOM operations so that the list item elements will be loaded.
+    afterNextRender(this, () => {
+      this.$.cursor.stops = this._getListItems();
+      this.$.cursor.moveToStart();
+    });
+  }
+
+  _getSpecialEmptySlot(section: DashboardSection) {
+    if (section.isOutgoing) return 'empty-outgoing';
+    if (section.name === YOUR_TURN.name) return 'empty-your-turn';
+    return '';
+  }
+
+  _isEmpty(section: DashboardSection) {
+    return !section.results?.length;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list': 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
deleted file mode 100644
index 17150df..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
+++ /dev/null
@@ -1,145 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
new file mode 100644
index 0000000..06957d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -0,0 +1,167 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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 aria-hidden="true" class="leftPadding"></td>
+            <td
+              aria-hidden="true"
+              class="star"
+              hidden$="[[!showStar]]"
+              hidden=""
+            ></td>
+            <td
+              class="cell"
+              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
+            >
+              <h2>
+                <a
+                  href$="[[_sectionHref(changeSection.query)]]"
+                  class="section-title"
+                >
+                  <span class="section-name">[[changeSection.name]]</span>
+                  <span class="section-count-label"
+                    >[[changeSection.countLabel]]</span
+                  >
+                </a>
+              </h2>
+            </td>
+          </tr>
+        </tbody>
+      </template>
+      <tbody class="groupContent">
+        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
+          <tr class="noChanges">
+            <td aria-hidden="true" class="leftPadding"></td>
+            <td aria-hidden="true" class="star" hidden></td>
+            <td
+              class="cell"
+              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
+            >
+              <template
+                is="dom-if"
+                if="[[_getSpecialEmptySlot(changeSection)]]"
+              >
+                <slot name="[[_getSpecialEmptySlot(changeSection)]]"></slot>
+              </template>
+              <template
+                is="dom-if"
+                if="[[!_getSpecialEmptySlot(changeSection)]]"
+              >
+                No changes
+              </template>
+            </td>
+          </tr>
+        </template>
+        <template is="dom-if" if="[[!_isEmpty(changeSection)]]">
+          <tr class="groupTitle">
+            <td aria-hidden="true" class="leftPadding"></td>
+            <td
+              aria-label="Star status column"
+              class="star"
+              hidden$="[[!showStar]]"
+              hidden=""
+            ></td>
+            <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
+            <template
+              is="dom-repeat"
+              items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
+              as="item"
+            >
+              <td class$="[[_lowerCase(item)]]">
+                [[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
+            account="[[account]]"
+            selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
+            highlight$="[[_computeItemHighlight(account, change, _config, changeSection.name)]]"
+            needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState, _config)]]"
+            change="[[change]]"
+            config="[[_config]]"
+            section-name="[[changeSection.name]]"
+            visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
+            show-number="[[showNumber]]"
+            show-star="[[showStar]]"
+            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex)]]"
+            label-names="[[labelNames]]"
+          ></gr-change-list-item>
+        </template>
+      </tbody>
+    </template>
+  </table>
+  <gr-cursor-manager
+    id="cursor"
+    index="{{selectedIndex}}"
+    scroll-mode="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
deleted file mode 100644
index 62763d9..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ /dev/null
@@ -1,653 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-change-list</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/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-list></gr-change-list>
-  </template>
-</test-fixture>
-
-<test-fixture id="grouped">
-  <template>
-    <gr-change-list></gr-change-list>
-  </template>
-</test-fixture>
-
-<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';
-
-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(() => {
-      element = fixture('basic');
-      element.account = null;
-      element.preferences = null;
-      element._config = {};
-    });
-
-    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};
-      element._config = {};
-      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};
-      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,
-          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', () => {
-    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();
-    });
-
-    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);
-      }
-    });
-  });
-
-  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();
-    });
-
-    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();
-    });
-
-    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();
-    });
-
-    test('bad column does not exist', () => {
-      const elementClass = '.bad';
-      assert.isNotOk(element.shadowRoot
-          .querySelector(elementClass));
-    });
-  });
-
-  suite('dashboard queries', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('query without age and limit unchanged', () => {
-      const query = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), query);
-    });
-
-    test('query with age and limit', () => {
-      const query = 'status:closed age:1week limit:10 owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with age', () => {
-      const query = 'status:closed age:1week owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with limit', () => {
-      const query = 'status:closed limit:10 owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with age as value and not key', () => {
-      const query = 'status:closed random:age';
-      const expectedQuery = 'status:closed random:age';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with limit as value and not key', () => {
-      const query = 'status:closed random:limit';
-      const expectedQuery = 'status:closed random:limit';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with -age key', () => {
-      const query = 'status:closed -age:1week';
-      const expectedQuery = 'status:closed';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-  });
-
-  suite('gr-change-list sections', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('keyboard shortcuts', done => {
-      element.selectedIndex = 0;
-      element.sections = [
-        {
-          results: [
-            {_number: 0},
-            {_number: 1},
-            {_number: 2},
-          ],
-        },
-        {
-          results: [
-            {_number: 3},
-            {_number: 4},
-            {_number: 5},
-          ],
-        },
-        {
-          results: [
-            {_number: 6},
-            {_number: 7},
-            {_number: 8},
-          ],
-        },
-      ];
-      flushAsynchronousOperations();
-      afterNextRender(element, () => {
-        const elementItems = dom(element.root).querySelectorAll(
-            'gr-change-list-item');
-        assert.equal(elementItems.length, 9);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-
-        const navStub = sandbox.stub(GerritNav, 'navigateToChange');
-        assert.equal(element.selectedIndex, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-            'Should navigate to /c/2/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-            'Should navigate to /c/1/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        assert.equal(element.selectedIndex, 4);
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
-            'Should navigate to /c/4/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-        const change = element._changeForIndex(element.selectedIndex);
-        assert.equal(change.reviewed, true,
-            'Should mark change as reviewed');
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-        assert.equal(change.reviewed, false,
-            'Should mark change as unreviewed');
-        done();
-      });
-    });
-
-    test('highlight attribute is updated correctly', () => {
-      element.changes = [
-        {
-          _number: 0,
-          status: 'NEW',
-          owner: {_account_id: 0},
-        },
-        {
-          _number: 1,
-          status: 'ABANDONED',
-          owner: {_account_id: 0},
-        },
-      ];
-      element.account = {_account_id: 42};
-      flushAsynchronousOperations();
-      let items = element._getListItems();
-      assert.equal(items.length, 2);
-      assert.isFalse(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
-
-      // Assign all issues to the user, but only the first one is highlighted
-      // because the second one is abandoned.
-      element.set(['changes', 0, 'assignee'], {_account_id: 12});
-      element.set(['changes', 1, 'assignee'], {_account_id: 12});
-      element.account = {_account_id: 12};
-      flushAsynchronousOperations();
-      items = element._getListItems();
-      assert.isTrue(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
-    });
-
-    test('_computeItemHighlight gives false for null account', () => {
-      assert.isFalse(
-          element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
-    });
-
-    test('_computeItemAbsoluteIndex', () => {
-      sandbox.stub(element, '_computeLabelNames');
-      element.sections = [
-        {results: new Array(1)},
-        {results: new Array(2)},
-        {results: new Array(3)},
-      ];
-
-      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
-      // Out of range but no matter.
-      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
-
-      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
-      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
-      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
-      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
-      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
new file mode 100644
index 0000000..494d05a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -0,0 +1,634 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-list.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-change-list');
+
+suite('gr-change-list basic tests', () => {
+  let element;
+
+  suiteSetup(() => {
+    const kb = TestKeyboardShortcutBinder.push();
+    kb.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
+    kb.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
+    kb.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
+    kb.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
+    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
+    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
+    kb.bindShortcut(Shortcut.NEXT_PAGE, 'n');
+    kb.bindShortcut(Shortcut.NEXT_PAGE, 'p');
+  });
+
+  suiteTeardown(() => {
+    TestKeyboardShortcutBinder.pop();
+  });
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('test show change number not logged in', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.account = undefined;
+      element.preferences = undefined;
+      element._config = {};
+    });
+
+    test('show number disabled', () => {
+      assert.isFalse(element.showNumber);
+    });
+  });
+
+  suite('test show change number preference enabled', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element.account = {_account_id: 1001};
+      element._config = {};
+      flush();
+    });
+
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+  });
+
+  suite('test show change number preference disabled', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      // legacycid_in_change_table is not set when false.
+      element.preferences = {
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element.account = {_account_id: 1001};
+      element._config = {};
+      flush();
+    });
+
+    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', () => {
+    element.sections = [
+      {results: [{}]},
+    ];
+    flush();
+    const tdItemCount = element.root.querySelectorAll(
+        'td').length;
+
+    const changeTableColumns = [];
+    const labelNames = [];
+    assert.equal(tdItemCount, element._computeColspan(
+        {}, changeTableColumns, labelNames));
+  });
+
+  test('keyboard shortcuts', done => {
+    sinon.stub(element, '_computeLabelNames');
+    element.sections = [
+      {results: new Array(1)},
+      {results: new Array(2)},
+    ];
+    element.selectedIndex = 0;
+    element.changes = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    flush();
+    afterNextRender(element, () => {
+      const elementItems = 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 = sinon.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 = sinon.stub(element, '_reloadWindow');
+      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      assert.isTrue(reloadStub.called);
+
+      done();
+    });
+  });
+
+  test('changes needing review', () => {
+    element.changes = [
+      {
+        _number: 0,
+        status: 'NEW',
+        reviewed: true,
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 1,
+        status: 'NEW',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 2,
+        status: 'MERGED',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 3,
+        status: 'ABANDONED',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 4,
+        status: 'NEW',
+        work_in_progress: true,
+        owner: {_account_id: 0},
+      },
+    ];
+    flush();
+    let elementItems = element.root.querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    for (let i = 0; i < elementItems.length; i++) {
+      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
+    }
+
+    element.showReviewedState = true;
+    elementItems = element.root.querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+
+    element.account = {_account_id: 42};
+    elementItems = element.root.querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+
+    element._config = {
+      change: {enable_attention_set: true},
+    };
+    elementItems = element.root.querySelectorAll(
+        'gr-change-list-item');
+    for (let i = 0; i < elementItems.length; i++) {
+      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
+    }
+  });
+
+  test('no changes', () => {
+    element.changes = [];
+    flush();
+    const listItems = element.root.querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(listItems.length, 0);
+    const noChangesMsg =
+        element.root.querySelector('.noChanges');
+    assert.ok(noChangesMsg);
+  });
+
+  test('empty sections', () => {
+    element.sections = [{results: []}, {results: []}];
+    flush();
+    const listItems = element.root.querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(listItems.length, 0);
+    const noChangesMsg = element.root.querySelectorAll(
+        '.noChanges');
+    assert.equal(noChangesMsg.length, 2);
+  });
+
+  suite('empty section', () => {
+    test('not shown on empty non-outgoing sections', () => {
+      const section = {results: []};
+      assert.isTrue(element._isEmpty(section));
+      assert.equal(element._getSpecialEmptySlot(section), '');
+    });
+
+    test('shown on empty outgoing sections', () => {
+      const section = {results: [], isOutgoing: true};
+      assert.isTrue(element._isEmpty(section));
+      assert.equal(element._getSpecialEmptySlot(section), 'empty-outgoing');
+    });
+
+    test('shown on empty outgoing sections', () => {
+      const section = {results: [], name: YOUR_TURN.name};
+      assert.isTrue(element._isEmpty(section));
+      assert.equal(element._getSpecialEmptySlot(section), 'empty-your-turn');
+    });
+
+    test('not shown on non-empty outgoing sections', () => {
+      const section = {isOutgoing: true, results: [
+        {_number: 0, labels: {Verified: {approved: {}}}}]};
+      assert.isFalse(element._isEmpty(section));
+    });
+  });
+
+  suite('empty column preference', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.sections = [
+        {results: [{}]},
+      ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element._config = {};
+      flush();
+    });
+
+    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);
+      }
+    });
+  });
+
+  suite('full column preference', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      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 = {};
+      flush();
+    });
+
+    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 = basicFixture.instantiate();
+      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 = {};
+      flush();
+    });
+
+    test('all columns except repo visible', () => {
+      for (const column of element.changeTableColumns) {
+        const elementClass = '.' + column.toLowerCase();
+        if (column === 'Repo') {
+          assert.isNotOk(element.shadowRoot.querySelector(elementClass));
+        } else {
+          assert.isOk(element.shadowRoot.querySelector(elementClass));
+        }
+      }
+    });
+  });
+
+  suite('random column does not exist', () => {
+    let element;
+
+    /* This would only exist if somebody manually updated the config
+    file. */
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Bad',
+        ],
+      };
+      flush();
+    });
+
+    test('bad column does not exist', () => {
+      const elementClass = '.bad';
+      assert.isNotOk(element.shadowRoot
+          .querySelector(elementClass));
+    });
+  });
+
+  suite('dashboard queries', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    teardown(() => { sinon.restore(); });
+
+    test('query without age and limit unchanged', () => {
+      const query = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), query);
+    });
+
+    test('query with age and limit', () => {
+      const query = 'status:closed age:1week limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with age', () => {
+      const query = 'status:closed age:1week owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with limit', () => {
+      const query = 'status:closed limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with age as value and not key', () => {
+      const query = 'status:closed random:age';
+      const expectedQuery = 'status:closed random:age';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with limit as value and not key', () => {
+      const query = 'status:closed random:limit';
+      const expectedQuery = 'status:closed random:limit';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with -age key', () => {
+      const query = 'status:closed -age:1week';
+      const expectedQuery = 'status:closed';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+  });
+
+  suite('gr-change-list sections', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    test('keyboard shortcuts', done => {
+      element.selectedIndex = 0;
+      element.sections = [
+        {
+          results: [
+            {_number: 0},
+            {_number: 1},
+            {_number: 2},
+          ],
+        },
+        {
+          results: [
+            {_number: 3},
+            {_number: 4},
+            {_number: 5},
+          ],
+        },
+        {
+          results: [
+            {_number: 6},
+            {_number: 7},
+            {_number: 8},
+          ],
+        },
+      ];
+      flush();
+      afterNextRender(element, () => {
+        const elementItems = element.root.querySelectorAll(
+            'gr-change-list-item');
+        assert.equal(elementItems.length, 9);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+
+        const navStub = sinon.stub(GerritNav, 'navigateToChange');
+        assert.equal(element.selectedIndex, 2);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+            'Should navigate to /c/2/');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+            'Should navigate to /c/1/');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        assert.equal(element.selectedIndex, 4);
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
+            'Should navigate to /c/4/');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        const change = element._changeForIndex(element.selectedIndex);
+        assert.equal(change.reviewed, true,
+            'Should mark change as reviewed');
+        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        assert.equal(change.reviewed, false,
+            'Should mark change as unreviewed');
+        done();
+      });
+    });
+
+    test('highlight attribute is updated correctly', () => {
+      element.changes = [
+        {
+          _number: 0,
+          status: 'NEW',
+          owner: {_account_id: 0},
+        },
+        {
+          _number: 1,
+          status: 'ABANDONED',
+          owner: {_account_id: 0},
+        },
+      ];
+      element.account = {_account_id: 42};
+      flush();
+      let items = element._getListItems();
+      assert.equal(items.length, 2);
+      assert.isFalse(items[0].hasAttribute('highlight'));
+      assert.isFalse(items[1].hasAttribute('highlight'));
+
+      // Assign all issues to the user, but only the first one is highlighted
+      // because the second one is abandoned.
+      element.set(['changes', 0, 'assignee'], {_account_id: 12});
+      element.set(['changes', 1, 'assignee'], {_account_id: 12});
+      element.account = {_account_id: 12};
+      flush();
+      items = element._getListItems();
+      assert.isTrue(items[0].hasAttribute('highlight'));
+      assert.isFalse(items[1].hasAttribute('highlight'));
+    });
+
+    test('_computeItemHighlight gives false for null account', () => {
+      assert.isFalse(
+          element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
+    });
+
+    test('_computeItemAbsoluteIndex', () => {
+      sinon.stub(element, '_computeLabelNames');
+      element.sections = [
+        {results: new Array(1)},
+        {results: new Array(2)},
+        {results: new Array(3)},
+      ];
+
+      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
+      // Out of range but no matter.
+      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
+
+      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
+      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
+      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
+      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
+      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
+    });
+  });
+});
+
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
deleted file mode 100644
index 3758a78..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @extends Polymer.Element */
-class GrCreateChangeHelp extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
new file mode 100644
index 0000000..35f3aeb
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -0,0 +1,50 @@
+/**
+ * @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.
+ */
+
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement} from '@polymer/decorators';
+import {htmlTemplate} from './gr-create-change-help_html';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-change-help': GrCreateChangeHelp;
+  }
+}
+
+@customElement('gr-create-change-help')
+class GrCreateChangeHelp extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the "Create change" button is tapped.
+   */
+  _handleCreateTap(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('create-tap', {bubbles: true, composed: true})
+    );
+  }
+}
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
deleted file mode 100644
index 4a357af..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
new file mode 100644
index 0000000..c2f97a6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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 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">
+    <h2 class="heading-3">Push your first change for code review</h2>
+    <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
deleted file mode 100644
index 9b8ed29..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
+++ /dev/null
@@ -1,50 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-create-change-help></gr-create-change-help>
-  </template>
-</test-fixture>
-
-<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.shadowRoot
-        .querySelector('gr-button'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.js
new file mode 100644
index 0000000..9dd0a35
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.js
@@ -0,0 +1,36 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-create-change-help.js';
+
+const basicFixture = fixtureFromElement('gr-create-change-help');
+
+suite('gr-create-change-help tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('Create change tap', done => {
+    element.addEventListener('create-tap', () => done());
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button'));
+  });
+});
+
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
deleted file mode 100644
index 7e5e749..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
+++ /dev/null
@@ -1,74 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const Commands = {
-  CREATE: 'git commit',
-  AMEND: 'git commit --amend',
-  PUSH_PREFIX: 'git push origin HEAD:refs/for/',
-};
-
-/** @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,
-        readonly: true,
-        value: Commands.CREATE,
-      },
-      _amendExistingCommitCommand: {
-        type: String,
-        readonly: true,
-        value: Commands.AMEND,
-      },
-      _pushCommand: {
-        type: String,
-        computed: '_computePushCommand(branch)',
-      },
-    };
-  }
-
-  open() {
-    this.$.commandsOverlay.open();
-  }
-
-  _handleClose() {
-    this.$.commandsOverlay.close();
-  }
-
-  _computePushCommand(branch) {
-    return Commands.PUSH_PREFIX + branch;
-  }
-}
-
-customElements.define(GrCreateCommandsDialog.is, GrCreateCommandsDialog);
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
new file mode 100644
index 0000000..1ee8cb5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
@@ -0,0 +1,80 @@
+/**
+ * @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.
+ */
+
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-shell-command/gr-shell-command';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-create-commands-dialog_html';
+
+enum Commands {
+  CREATE = 'git commit',
+  AMEND = 'git commit --amend',
+  PUSH_PREFIX = 'git push origin HEAD:refs/for/',
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-commands-dialog': GrCreateCommandsDialog;
+  }
+}
+
+export interface GrCreateCommandsDialog {
+  $: {
+    commandsOverlay: GrOverlay;
+  };
+}
+
+@customElement('gr-create-commands-dialog')
+export class GrCreateCommandsDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  branch?: string;
+
+  @property({type: String})
+  readonly _createNewCommitCommand = Commands.CREATE;
+
+  @property({type: String})
+  readonly _amendExistingCommitCommand = Commands.AMEND;
+
+  @property({
+    type: String,
+    computed: '_computePushCommand(branch)',
+  })
+  _pushCommand?: string;
+
+  open() {
+    this.$.commandsOverlay.open();
+  }
+
+  _handleClose() {
+    this.$.commandsOverlay.close();
+  }
+
+  _computePushCommand(branch: string): string {
+    return Commands.PUSH_PREFIX + branch;
+  }
+}
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
deleted file mode 100644
index d2a1af9..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.ts
new file mode 100644
index 0000000..9031738
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.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.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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
deleted file mode 100644
index e6cd587..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
+++ /dev/null
@@ -1,54 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-create-commands-dialog></gr-create-commands-dialog>
-  </template>
-</test-fixture>
-
-<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');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js
new file mode 100644
index 0000000..9dbcd29
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js
@@ -0,0 +1,40 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-create-commands-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-create-commands-dialog');
+
+suite('gr-create-commands-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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');
+  });
+});
+
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
deleted file mode 100644
index f8757ba..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
+++ /dev/null
@@ -1,79 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * 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; }
-
-  static get is() { return 'gr-create-destination-dialog'; }
-
-  static get properties() {
-    return {
-      _repo: String,
-      _branch: String,
-      _repoAndBranchSelected: {
-        type: Boolean,
-        value: false,
-        computed: '_computeRepoAndBranchSelected(_repo, _branch)',
-      },
-    };
-  }
-
-  open() {
-    this._repo = '';
-    this._branch = '';
-    this.$.createOverlay.open();
-  }
-
-  _handleClose() {
-    this.$.createOverlay.close();
-  }
-
-  _pickerConfirm(e) {
-    this.$.createOverlay.close();
-    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.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
new file mode 100644
index 0000000..e53f68b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -0,0 +1,98 @@
+/**
+ * @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.
+ */
+
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-create-destination-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RepoName, BranchName} from '../../../types/common';
+
+export interface CreateDestinationConfirmDetail {
+  repo?: RepoName;
+  branch?: BranchName;
+}
+
+/**
+ * Fired when a destination has been picked. Event details contain the repo
+ * name and the branch name.
+ *
+ * @event confirm
+ */
+export interface GrCreateDestinationDialog {
+  $: {
+    createOverlay: GrOverlay;
+  };
+}
+
+@customElement('gr-create-destination-dialog')
+export class GrCreateDestinationDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  _repo?: RepoName;
+
+  @property({type: String})
+  _branch?: BranchName;
+
+  @property({
+    type: Boolean,
+    computed: '_computeRepoAndBranchSelected(_repo, _branch)',
+  })
+  _repoAndBranchSelected = false;
+
+  open() {
+    this._repo = '' as RepoName;
+    this._branch = '' as BranchName;
+    this.$.createOverlay.open();
+  }
+
+  _handleClose() {
+    this.$.createOverlay.close();
+  }
+
+  _pickerConfirm(e: Event) {
+    this.$.createOverlay.close();
+    const detail: CreateDestinationConfirmDetail = {
+      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?: RepoName, branch?: BranchName) {
+    return !!(repo && branch);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-destination-dialog': 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
deleted file mode 100644
index c7cd647..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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-create-destination-dialog/gr-create-destination-dialog_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts
new file mode 100644
index 0000000..9155d9a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts
@@ -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';
+
+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.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
deleted file mode 100644
index 8b8b981..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ /dev/null
@@ -1,336 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
-
-/**
- * @extends Polymer.Element
- */
-class GrDashboardView extends mixinBehaviors( [
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  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,
-      },
-      preferences: Object,
-      /** @type {{ selectedChangeIndex: number }} */
-      viewState: Object,
-
-      /** @type {{ project: string, user: string }} */
-      params: {
-        type: Object,
-      },
-
-      createChangeTap: {
-        type: Function,
-        value() {
-          return this._createChangeTap.bind(this);
-        },
-      },
-
-      _results: Array,
-
-      /**
-       * For showing a "loading..." string during ajax requests.
-       */
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-
-      _showDraftsBanner: {
-        type: Boolean,
-        value: false,
-      },
-
-      _showNewUserHelp: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_paramsChanged(params.*)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.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.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),
-          };
-        }),
-      };
-    });
-  }
-
-  _computeTitle(user) {
-    if (!user || user === 'self') {
-      return 'My Reviews';
-    }
-    return 'Dashboard for ' + user;
-  }
-
-  _isViewActive(params) {
-    return params.view === GerritNav.View.DASHBOARD;
-  }
-
-  _paramsChanged(paramsChangeRecord) {
-    const params = paramsChangeRecord.base;
-
-    if (!this._isViewActive(params)) {
-      return Promise.resolve();
-    }
-
-    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(GerritNav.getUserDashboard(
-          user,
-          sections,
-          title || this._computeTitle(user)));
-
-    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),
-            },
-            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(); }
-
-    const queries = res.sections
-        .map(section => (section.suffixForDashboard ?
-          section.query + ' ' + section.suffixForDashboard :
-          section.query));
-
-    if (checkForNewUser) {
-      queries.push('owner:self limit:1');
-    }
-
-    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));
-        });
-  }
-
-  _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';
-    }
-    return '';
-  }
-
-  _handleToggleStar(e) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number,
-        e.detail.starred);
-  }
-
-  _handleToggleReviewed(e) {
-    this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-        e.detail.reviewed);
-  }
-
-  /**
-   * Banner is shown if a user is on their own dashboard and they have draft
-   * comments on closed changes.
-   */
-  _maybeShowDraftsBanner() {
-    this._showDraftsBanner = false;
-    if (!(this.params.user === 'self')) { return; }
-
-    const draftSection = this._results
-        .find(section => section.query === 'has:draft');
-    if (!draftSection || !draftSection.results.length) { return; }
-
-    const closedChanges = draftSection.results
-        .filter(change => !this.changeIsOpen(change));
-    if (!closedChanges.length) { return; }
-
-    this._showDraftsBanner = true;
-  }
-
-  _computeBannerClass(show) {
-    return show ? '' : 'hide';
-  }
-
-  _handleOpenDeleteDialog() {
-    this.$.confirmDeleteOverlay.open();
-  }
-
-  _handleConfirmDelete() {
-    this.$.confirmDeleteDialog.disabled = true;
-    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
-      this._closeConfirmDeleteOverlay();
-      this._reload();
-    });
-  }
-
-  _closeConfirmDeleteOverlay() {
-    this.$.confirmDeleteOverlay.close();
-  }
-
-  _computeDraftsLink() {
-    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
-  }
-
-  _createChangeTap(e) {
-    this.$.destinationDialog.open();
-  }
-
-  _handleDestinationConfirm(e) {
-    this.$.commandsDialog.branch = e.detail.branch;
-    this.$.commandsDialog.open();
-  }
-}
-
-customElements.define(GrDashboardView.is, GrDashboardView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
new file mode 100644
index 0000000..57d3dd9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -0,0 +1,450 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../gr-change-list/gr-change-list';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-commands-dialog/gr-create-commands-dialog';
+import '../gr-create-change-help/gr-create-change-help';
+import '../gr-create-destination-dialog/gr-create-destination-dialog';
+import '../gr-user-header/gr-user-header';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-dashboard-view_html';
+import {
+  GerritNav,
+  GerritView,
+  UserDashboard,
+  YOUR_TURN,
+} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {changeIsOpen} from '../../../utils/change-util';
+import {parseDate} from '../../../utils/date-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+  DashboardId,
+  ElementPropertyDeepChange,
+  PreferencesInput,
+  RepoName,
+} from '../../../types/common';
+import {AppElementDashboardParams, AppElementParams} from '../../gr-app-types';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
+import {
+  CreateDestinationConfirmDetail,
+  GrCreateDestinationDialog,
+} from '../gr-create-destination-dialog/gr-create-destination-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+import {DashboardViewState} from '../../../types/types';
+
+const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+
+export interface GrDashboardView {
+  $: {
+    restAPI: RestApiService & Element;
+    confirmDeleteDialog: GrDialog;
+    commandsDialog: GrCreateCommandsDialog;
+    destinationDialog: GrCreateDestinationDialog;
+    confirmDeleteOverlay: GrOverlay;
+  };
+}
+
+interface DashboardChange {
+  name: string;
+  countLabel: string;
+  query: string;
+  results: ChangeInfo[];
+  isOutgoing?: boolean;
+}
+
+@customElement('gr-dashboard-view')
+export class GrDashboardView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  @property({type: Object})
+  account: AccountDetailInfo | null = null;
+
+  @property({type: Object})
+  preferences?: PreferencesInput;
+
+  @property({type: Object})
+  viewState?: DashboardViewState;
+
+  @property({type: Object})
+  params?: AppElementParams;
+
+  @property({type: Array})
+  _results?: DashboardChange[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean})
+  _showDraftsBanner = false;
+
+  @property({type: Boolean})
+  _showNewUserHelp = false;
+
+  private reporting = appContext.reportingService;
+
+  constructor() {
+    super();
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+    this.addEventListener('reload', e => {
+      e.stopPropagation();
+      this._reload();
+    });
+  }
+
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this.$.restAPI.getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
+      }
+    });
+  }
+
+  _getProjectDashboard(
+    project: RepoName,
+    dashboard: DashboardId
+  ): Promise<UserDashboard | undefined> {
+    const errFn = (response?: Response | null) => {
+      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
+              ),
+            };
+          }),
+        };
+      });
+  }
+
+  _computeTitle(user?: string) {
+    if (!user || user === 'self') {
+      return 'My Reviews';
+    }
+    return 'Dashboard for ' + user;
+  }
+
+  _isViewActive(params: AppElementParams): params is AppElementDashboardParams {
+    return params.view === GerritView.DASHBOARD;
+  }
+
+  @observe('params.*')
+  _paramsChanged(
+    paramsChangeRecord: ElementPropertyDeepChange<GrDashboardView, 'params'>
+  ) {
+    const params = paramsChangeRecord.base;
+
+    return this._reload(params);
+  }
+
+  /**
+   * Reloads the element.
+   */
+  _reload(params?: AppElementParams) {
+    if (!params || !this._isViewActive(params)) {
+      return Promise.resolve();
+    }
+    this._loading = true;
+    const {project, dashboard, title, user, sections} = params;
+    const dashboardPromise: Promise<UserDashboard | undefined> = project
+      ? this._getProjectDashboard(project, dashboard)
+      : this.$.restAPI
+          .getConfig()
+          .then(config =>
+            Promise.resolve(
+              GerritNav.getUserDashboard(
+                user,
+                sections,
+                title || this._computeTitle(user),
+                config
+              )
+            )
+          );
+
+    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(params);
+        this.reporting.dashboardDisplayed();
+      })
+      .catch(err => {
+        this.dispatchEvent(
+          new CustomEvent('title-change', {
+            detail: {
+              title: title || this._computeTitle(user),
+            },
+            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.
+   */
+  _fetchDashboardChanges(
+    res: UserDashboard | undefined,
+    checkForNewUser: boolean
+  ): Promise<void> {
+    if (!res) {
+      return Promise.resolve();
+    }
+
+    let queries: string[];
+
+    if (window.PRELOADED_QUERIES && window.PRELOADED_QUERIES.dashboardQuery) {
+      queries = window.PRELOADED_QUERIES.dashboardQuery;
+      // we use preloaded query from index only on first page load
+      window.PRELOADED_QUERIES.dashboardQuery = undefined;
+    } else {
+      queries = res.sections.map(section =>
+        section.suffixForDashboard
+          ? section.query + ' ' + section.suffixForDashboard
+          : section.query
+      );
+
+      if (checkForNewUser) {
+        queries.push('owner:self limit:1');
+      }
+    }
+
+    return this.$.restAPI.getChanges(undefined, queries).then(changes => {
+      if (!changes) {
+        throw new Error('getChanges returns undefined');
+      }
+      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: this._maybeSortResults(res.sections[i].name, results),
+            isOutgoing: res.sections[i].isOutgoing,
+          };
+        })
+        .filter(
+          (section, i) =>
+            i < res.sections.length &&
+            (!res.sections[i].hideIfEmpty || section.results.length)
+        );
+    });
+  }
+
+  /**
+   * Usually we really want to stick to the sorting that the backend provides,
+   * but for the "Your Turn" section it is important to put the changes at the
+   * top where the current user is a reviewer. Owned changes are less important.
+   * And then we want to emphasize the changes where the waiting time is larger.
+   */
+  _maybeSortResults(name: string, results: ChangeInfo[]) {
+    const userId = this.account && this.account._account_id;
+    const sortedResults = [...results];
+    if (name === YOUR_TURN.name && userId) {
+      sortedResults.sort((c1, c2) => {
+        const c1Owner = c1.owner._account_id === userId;
+        const c2Owner = c2.owner._account_id === userId;
+        if (c1Owner !== c2Owner) return c1Owner ? 1 : -1;
+        // Should never happen, because the change is in the 'Your Turn'
+        // section, so the userId should be found in the attention set of both.
+        if (!c1.attention_set || !c1.attention_set[userId]) return 0;
+        if (!c2.attention_set || !c2.attention_set[userId]) return 0;
+        const c1Update = c1.attention_set[userId].last_update;
+        const c2Update = c2.attention_set[userId].last_update;
+        // Should never happen that an attention set entry has no update.
+        if (!c1Update || !c2Update) return c1Update ? 1 : -1;
+        return parseDate(c1Update).valueOf() - parseDate(c2Update).valueOf();
+      });
+    }
+    return sortedResults;
+  }
+
+  _computeSectionCountLabel(changes: ChangeInfo[]) {
+    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: AppElementParams) {
+    if (
+      !params ||
+      params.view !== GerritView.DASHBOARD ||
+      !!params.project ||
+      !params.user ||
+      params.user === 'self'
+    ) {
+      return 'hide';
+    }
+    return '';
+  }
+
+  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+  }
+
+  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+    this.$.restAPI.saveChangeReviewed(
+      e.detail.change._number,
+      e.detail.reviewed
+    );
+  }
+
+  /**
+   * Banner is shown if a user is on their own dashboard and they have draft
+   * comments on closed changes.
+   */
+  _maybeShowDraftsBanner(params: AppElementDashboardParams) {
+    this._showDraftsBanner = false;
+    if (!(params.user === 'self')) {
+      return;
+    }
+
+    if (!this._results) {
+      throw new Error('this._results must be set. restAPI returned undefined');
+    }
+
+    const draftSection = this._results.find(
+      section => section.query === 'has:draft'
+    );
+    if (!draftSection || !draftSection.results.length) {
+      return;
+    }
+
+    const closedChanges = draftSection.results.filter(
+      change => !changeIsOpen(change)
+    );
+    if (!closedChanges.length) {
+      return;
+    }
+
+    this._showDraftsBanner = true;
+  }
+
+  _computeBannerClass(show: boolean) {
+    return show ? '' : 'hide';
+  }
+
+  _handleOpenDeleteDialog() {
+    this.$.confirmDeleteOverlay.open();
+  }
+
+  _handleConfirmDelete() {
+    this.$.confirmDeleteDialog.disabled = true;
+    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+      this._closeConfirmDeleteOverlay();
+      this._reload(this.params);
+    });
+  }
+
+  _closeConfirmDeleteOverlay() {
+    this.$.confirmDeleteOverlay.close();
+  }
+
+  _computeDraftsLink() {
+    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
+  }
+
+  _handleCreateChangeTap() {
+    this.$.destinationDialog.open();
+  }
+
+  _handleDestinationConfirm(e: CustomEvent<CreateDestinationConfirmDetail>) {
+    this.$.commandsDialog.branch = e.detail.branch;
+    this.$.commandsDialog.open();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-dashboard-view': 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
deleted file mode 100644
index 3389bd0..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
new file mode 100644
index 0000000..6dae176
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -0,0 +1,129 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    #emptyYourTurn {
+      text-align: center;
+    }
+    @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>
+    <h1 class="assistive-tech-only">Dashboard</h1>
+    <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="_handleCreateChangeTap"
+          ></gr-create-change-help>
+        </template>
+        <template is="dom-if" if="[[!_showNewUserHelp]]">
+          No changes
+        </template>
+      </div>
+      <div id="emptyYourTurn" slot="empty-your-turn">
+        <span>&#x1f389; No changes need your attention &#x1f389;</span>
+      </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>
+`;
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
deleted file mode 100644
index 5965d06..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ /dev/null
@@ -1,381 +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">
-<meta charset="utf-8">
-<title>gr-dashboard-view</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-dashboard-view></gr-dashboard-view>
-  </template>
-</test-fixture>
-
-<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';
-
-suite('gr-dashboard-view tests', () => {
-  let element;
-  let sandbox;
-  let paramsChangedPromise;
-  let getChangesStub;
-
-  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);
-      });
-
-      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.shadowRoot
-          .querySelector('.banner')));
-
-      element._showDraftsBanner = true;
-      flushAsynchronousOperations();
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('.banner')));
-    });
-
-    test('delete tap opens dialog', () => {
-      sandbox.stub(element, '_handleOpenDeleteDialog');
-      element._showDraftsBanner = true;
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.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: GerritNav.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: 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-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
new file mode 100644
index 0000000..44f203d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -0,0 +1,389 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-dashboard-view.js';
+import {isHidden} from '../../../test/test-utils.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {changeIsOpen} from '../../../utils/change-util.js';
+import {ChangeStatus} from '../../../constants/constants.js';
+
+const basicFixture = fixtureFromElement('gr-dashboard-view');
+
+suite('gr-dashboard-view tests', () => {
+  let element;
+
+  let paramsChangedPromise;
+  let getChangesStub;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getAccountDetails() { return Promise.resolve({}); },
+      getAccountStatus() { return Promise.resolve(false); },
+    });
+    element = basicFixture.instantiate();
+
+    getChangesStub = sinon.stub(element.$.restAPI, 'getChanges').callsFake(
+        (_, qs) => Promise.resolve(qs.map(() => [])));
+
+    let resolver;
+    paramsChangedPromise = new Promise(resolve => {
+      resolver = resolve;
+    });
+    const paramsChanged = element._paramsChanged.bind(element);
+    sinon.stub(element, '_paramsChanged').callsFake( params => {
+      paramsChanged(params).then(() => resolver());
+    });
+  });
+
+  suite('drafts banner functionality', () => {
+    suite('_maybeShowDraftsBanner', () => {
+      test('not dashboard/self', () => {
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'notself',
+        });
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts at all', () => {
+        element._results = [];
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'self',
+        });
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts on open changes', () => {
+        const openChange = {status: ChangeStatus.NEW};
+        element._results = [{query: 'has:draft', results: [openChange]}];
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'self',
+        });
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts on not open changes', () => {
+        const notOpenChange = {status: '_'};
+        element._results = [{query: 'has:draft', results: [notOpenChange]}];
+        assert.isFalse(changeIsOpen(element._results[0].results[0]));
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'self',
+        });
+        assert.isTrue(element._showDraftsBanner);
+      });
+    });
+
+    test('_showDraftsBanner', () => {
+      element._showDraftsBanner = false;
+      flush();
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('.banner')));
+
+      element._showDraftsBanner = true;
+      flush();
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('.banner')));
+    });
+
+    test('delete tap opens dialog', () => {
+      sinon.stub(element, '_handleOpenDeleteDialog');
+      element._showDraftsBanner = true;
+      flush();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.banner .delete'));
+      assert.isTrue(element._handleOpenDeleteDialog.called);
+    });
+
+    test('delete comments flow', async () => {
+      sinon.spy(element, '_handleConfirmDelete');
+      sinon.stub(element, '_reload');
+
+      // Set up control over timing of when RPC resolves.
+      let deleteDraftCommentsPromiseResolver;
+      const deleteDraftCommentsPromise = new Promise(resolve => {
+        deleteDraftCommentsPromiseResolver = resolve;
+      });
+      sinon.stub(element.$.restAPI, 'deleteDraftComments')
+          .returns(deleteDraftCommentsPromise);
+
+      // Open confirmation dialog and tap confirm button.
+      await element.$.confirmDeleteOverlay.open();
+      MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
+      flush();
+      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 = {};
+      flush();
+      assert.equal(getChangesStub.callCount, 0);
+
+      element.params = {user: ''};
+      flush();
+      assert.equal(getChangesStub.callCount, 0);
+    });
+
+    test('content is refreshed when user param is updated', () => {
+      element.params = {
+        view: GerritNav.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: GerritNav.View.DASHBOARD,
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'self',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(
+            getChangesStub.calledWith(undefined,
+                ['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(undefined, ['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, [undefined, ['1', '2 suffix']]);
+    });
+  });
+
+  suite('_getProjectDashboard', () => {
+    test('dashboard with foreach', () => {
+      sinon.stub(element.$.restAPI, 'getDashboard')
+          .callsFake( () => 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', () => {
+      sinon.stub(element.$.restAPI, 'getDashboard').callsFake(
+          () => 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();
+    sinon.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();
+    sinon.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;
+    flush();
+
+    assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+    assert.isNotOk(element.shadowRoot
+        .querySelector('gr-create-change-help'));
+    element._showNewUserHelp = true;
+    flush();
+
+    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'}), 'hide');
+    assert.equal(
+        element._computeUserHeaderClass({
+          view: GerritView.DASHBOARD,
+          user: 'user',
+        }),
+        '');
+    assert.equal(
+        element._computeUserHeaderClass({project: 'p', user: 'user'}),
+        'hide');
+    assert.equal(
+        element._computeUserHeaderClass({
+          view: GerritView.DASHBOARD,
+          project: 'p',
+          user: 'user',
+        }),
+        'hide');
+  });
+
+  test('404 page', done => {
+    const response = {status: 404};
+    sinon.stub(element.$.restAPI, 'getDashboard').callsFake(
+        async (project, dashboard, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.strictEqual(e.detail.response, response);
+      paramsChangedPromise.then(done);
+    });
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+    };
+  });
+
+  test('params change triggers dashboardDisplayed()', () => {
+    sinon.stub(element.reporting, 'dashboardDisplayed');
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+    };
+    return paramsChangedPromise.then(() => {
+      assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
+    });
+  });
+});
+
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
deleted file mode 100644
index 2523700..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
+++ /dev/null
@@ -1,44 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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
deleted file mode 100644
index 802e365..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
deleted file mode 100644
index 5f0021e..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
+++ /dev/null
@@ -1,58 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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} */
-      _repoUrl: String,
-    };
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
new file mode 100644
index 0000000..e501cfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -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.
+ */
+
+import '../../../styles/dashboard-header-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-header_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {RepoName} from '../../../types/common';
+
+/** @extends PolymerElement */
+@customElement('gr-repo-header')
+class GrRepoHeader extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, observer: '_repoChanged'})
+  repo?: string;
+
+  @property({type: String})
+  _repoUrl: string | null = null;
+
+  _repoChanged(repoName: RepoName) {
+    if (!repoName) {
+      this._repoUrl = null;
+      return;
+    }
+    this._repoUrl = GerritNav.getUrlForRepo(repoName);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-header': 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
deleted file mode 100644
index f6fb1d0..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
new file mode 100644
index 0000000..d1221d15
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
@@ -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';
+
+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="heading-1">
+      [[repo]]
+    </h1>
+    <hr />
+    <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
deleted file mode 100644
index 78c1f09..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
+++ /dev/null
@@ -1,59 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-repo-header></gr-repo-header>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-header.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-repo-header tests', () => {
-  let element;
-  let sandbox;
-
-  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-repo-header/gr-repo-header_test.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.js
new file mode 100644
index 0000000..4f93d54
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.js
@@ -0,0 +1,40 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-header.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-repo-header');
+
+suite('gr-repo-header tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('repoUrl reset once repo changed', () => {
+    sinon.stub(GerritNav, 'getUrlForRepo').callsFake(
+        repoName => `http://test.com/${repoName}`
+    );
+    assert.equal(element._repoUrl, undefined);
+    element.repo = 'test';
+    assert.equal(element._repoUrl, 'http://test.com/test');
+  });
+});
+
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
deleted file mode 100644
index 6bb1bf8..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.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-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';
-
-/**
- * @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',
-      },
-
-      showDashboardLink: {
-        type: Boolean,
-        value: false,
-      },
-
-      loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * @type {?{name: ?, email: ?, registered_on: ?}}
-       */
-      _accountDetails: {
-        type: Object,
-        value: null,
-      },
-
-      /** @type {?string} */
-      _status: {
-        type: String,
-        value: null,
-      },
-    };
-  }
-
-  _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;
-    });
-  }
-
-  _computeDisplayClass(status) {
-    return status ? ' ' : 'hide';
-  }
-
-  _computeDetail(accountDetails, name) {
-    return accountDetails ? accountDetails[name] : '';
-  }
-
-  _computeStatusClass(accountDetails) {
-    return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
-  }
-
-  _computeDashboardUrl(accountDetails) {
-    if (!accountDetails) { return null; }
-    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';
-  }
-}
-
-customElements.define(GrUserHeader.is, GrUserHeader);
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
new file mode 100644
index 0000000..055c82c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -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.
+ */
+
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-avatar/gr-avatar';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/dashboard-header-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-user-header_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountDetailInfo, AccountId} from '../../../types/common';
+import {getDisplayName} from '../../../utils/display-name-util';
+
+export interface GrUserHeader {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-user-header')
+export class GrUserHeader extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, observer: '_accountChanged'})
+  userId?: AccountId;
+
+  @property({type: Boolean})
+  showDashboardLink = false;
+
+  @property({type: Boolean})
+  loggedIn = false;
+
+  @property({type: Object})
+  _accountDetails: AccountDetailInfo | null = null;
+
+  @property({type: String})
+  _status = '';
+
+  _accountChanged(userId?: AccountId) {
+    if (!userId) {
+      this._accountDetails = null;
+      this._status = '';
+      return;
+    }
+
+    this.$.restAPI.getAccountDetails(userId).then(details => {
+      this._accountDetails = details ?? null;
+      this._status = details?.status ?? '';
+    });
+  }
+
+  _computeDetail(
+    accountDetails: AccountDetailInfo | null,
+    name: keyof AccountDetailInfo
+  ) {
+    return accountDetails ? accountDetails[name] : '';
+  }
+
+  _computeHeading(accountDetails: AccountDetailInfo | null) {
+    if (!accountDetails) return '';
+    return getDisplayName(undefined, accountDetails);
+  }
+
+  _computeStatusClass(status: string) {
+    return status ? '' : 'hide';
+  }
+
+  _computeDashboardUrl(accountDetails: AccountDetailInfo | null) {
+    if (!accountDetails) {
+      return null;
+    }
+    const id = accountDetails._account_id;
+    if (id) {
+      return GerritNav.getUrlForUserDashboard(String(id));
+    }
+    const email = accountDetails.email;
+    if (email) {
+      return GerritNav.getUrlForUserDashboard(email);
+    }
+    return null;
+  }
+
+  _computeDashboardLinkClass(showDashboardLink: boolean, loggedIn: boolean) {
+    return showDashboardLink && loggedIn
+      ? 'dashboardLink'
+      : 'dashboardLink hide';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-user-header': 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
deleted file mode 100644
index 5a5d590..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
new file mode 100644
index 0000000..002a4ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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">
+    .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="heading-1">
+      [[_computeHeading(_accountDetails)]]
+    </h1>
+    <hr />
+    <div class$="status [[_computeStatusClass(_status)]]">
+      <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
deleted file mode 100644
index 44eb96c..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
+++ /dev/null
@@ -1,81 +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">
-<meta charset="utf-8">
-<title>gr-user-header</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-user-header></gr-user-header>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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'));
-
-    element.userId = 'foo.bar@baz';
-    flush(() => {
-      assert.isOk(element._accountDetails);
-      assert.isOk(element._status);
-
-      element.userId = null;
-      flush(() => {
-        flushAsynchronousOperations();
-        assert.isNull(element._accountDetails);
-        assert.isNull(element._status);
-
-        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');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
new file mode 100644
index 0000000..a808f3c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
@@ -0,0 +1,62 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-user-header.js';
+
+const basicFixture = fixtureFromElement('gr-user-header');
+
+suite('gr-user-header tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('loads and clears account info', done => {
+    sinon.stub(element.$.restAPI, 'getAccountDetails')
+        .returns(Promise.resolve({
+          name: 'foo',
+          email: 'bar',
+          status: 'OOO',
+          registered_on: '2015-03-12 18:32:08.000000000',
+        }));
+
+    element.userId = 'foo.bar@baz';
+    flush(() => {
+      assert.isOk(element._accountDetails);
+      assert.isOk(element._status);
+
+      element.userId = null;
+      flush(() => {
+        flush();
+        assert.isNull(element._accountDetails);
+        assert.equal(element._status, '');
+
+        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');
+  });
+});
+
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
deleted file mode 100644
index ca9016f..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ /dev/null
@@ -1,1680 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 = {
-  /**
-   * This label provides what is necessary for submission.
-   */
-  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 {
-    /**
-     * @type {{
-     *    _number: number,
-     *    branch: string,
-     *    id: string,
-     *    project: string,
-     *    subject: string,
-     *  }}
-     */
-      change: Object,
-      actions: {
-        type: Object,
-        value() { return {}; },
-      },
-      primaryActionKeys: {
-        type: Array,
-        value() {
-          return [
-            ChangeActions.READY,
-            RevisionActions.SUBMIT,
-          ];
-        },
-      },
-      disableEdit: {
-        type: Boolean,
-        value: false,
-      },
-      _hasKnownChainState: {
-        type: Boolean,
-        value: false,
-      },
-      _hideQuickApproveAction: {
-        type: Boolean,
-        value: false,
-      },
-      changeNum: String,
-      changeStatus: String,
-      commitNum: String,
-      hasParent: {
-        type: Boolean,
-        observer: '_computeChainState',
-      },
-      latestPatchNum: String,
-      commitMessage: {
-        type: String,
-        value: '',
-      },
-      /** @type {?} */
-      revisionActions: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-      },
-      // If property binds directly to [[revisionActions.submit]] it is not
-      // updated when revisionActions doesn't contain submit action.
-      /** @type {?} */
-      _revisionSubmitAction: {
-        type: Object,
-        computed: '_getSubmitAction(revisionActions)',
-      },
-      // If property binds directly to [[revisionActions.rebase]] it is not
-      // updated when revisionActions doesn't contain rebase action.
-      /** @type {?} */
-      _revisionRebaseAction: {
-        type: Object,
-        computed: '_getRebaseAction(revisionActions)',
-      },
-      privateByDefault: String,
-
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _actionLoadingMessage: {
-        type: String,
-        value: '',
-      },
-      _allActionValues: {
-        type: Array,
-        computed: '_computeAllActions(actions.*, revisionActions.*,' +
-          'primaryActionKeys.*, _additionalActions.*, change, ' +
-          '_actionPriorityOverrides.*)',
-      },
-      _topLevelActions: {
-        type: Array,
-        computed: '_computeTopLevelActions(_allActionValues.*, ' +
-          '_hiddenActions.*, _overflowActions.*)',
-        observer: '_filterPrimaryActions',
-      },
-      _topLevelPrimaryActions: Array,
-      _topLevelSecondaryActions: Array,
-      _menuActions: {
-        type: Array,
-        computed: '_computeMenuActions(_allActionValues.*, ' +
-          '_hiddenActions.*, _overflowActions.*)',
-      },
-      _overflowActions: {
-        type: Array,
-        value() {
-          const value = [
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.WIP,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.DELETE,
-            },
-            {
-              type: ActionType.REVISION,
-              key: RevisionActions.CHERRYPICK,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.MOVE,
-            },
-            {
-              type: ActionType.REVISION,
-              key: RevisionActions.DOWNLOAD,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.IGNORE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.UNIGNORE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.REVIEWED,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.UNREVIEWED,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.PRIVATE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.PRIVATE_DELETE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.FOLLOW_UP,
-            },
-          ];
-          return value;
-        },
-      },
-      _actionPriorityOverrides: {
-        type: Array,
-        value() { return []; },
-      },
-      _additionalActions: {
-        type: Array,
-        value() { return []; },
-      },
-      _hiddenActions: {
-        type: Array,
-        value() { return []; },
-      },
-      _disabledMenuActions: {
-        type: Array,
-        value() { return []; },
-      },
-      // editPatchsetLoaded == "does the current selected patch range have
-      // 'edit' as one of either basePatchNum or patchNum".
-      editPatchsetLoaded: {
-        type: Boolean,
-        value: false,
-      },
-      // editMode == "is edit mode enabled in the file list".
-      editMode: {
-        type: Boolean,
-        value: false,
-      },
-      editBasedOnCurrentPatchSet: {
-        type: Boolean,
-        value: true,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
-      '_changeChanged(change)',
-      '_editStatusChanged(editMode, editPatchsetLoaded, ' +
-        'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
-    ];
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('fullscreen-overlay-opened',
-        () => this._handleHideBackgroundContent());
-    this.addEventListener('fullscreen-overlay-closed',
-        () => this._handleShowBackgroundContent());
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
-    this._handleLoadingComplete();
-  }
-
-  _getSubmitAction(revisionActions) {
-    return this._getRevisionAction(revisionActions, 'submit', null);
-  }
-
-  _getRebaseAction(revisionActions) {
-    return this._getRevisionAction(revisionActions, 'rebase', 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];
-  }
-
-  reload() {
-    if (!this.changeNum || !this.latestPatchNum) {
-      return Promise.resolve();
-    }
-
-    this._loading = true;
-    return this._getRevisionActions()
-        .then(revisionActions => {
-          if (!revisionActions) { return; }
-
-          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;
-        });
-  }
-
-  _handleLoadingComplete() {
-    pluginLoader.awaitPluginsLoaded().then(() => this._loading = false);
-  }
-
-  _sendShowRevisionActions(detail) {
-    this.$.jsAPI.handleEvent(
-        this.$.jsAPI.EventType.SHOW_REVISION_ACTIONS,
-        detail
-    );
-  }
-
-  _changeChanged() {
-    this.reload();
-  }
-
-  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;
-      }
-    }
-    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');
-      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 {
-        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]);
-  }
-
-  _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;
-      }
-      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) {
-        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,
-        };
-      }
-    }
-    return null;
-  }
-
-  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,
-        });
-        return;
-      } else if (!values.includes(a)) {
-        return;
-      }
-      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;
-      }
-    }
-    return null;
-  }
-
-  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);
-        });
-  }
-
-  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,
-        }
-    );
-  }
-
-  _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,
-        }
-    );
-  }
-
-  _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');
-    }
-  }
-
-  _handleRevertSubmissionDialogConfirm() {
-    const el = this.$.confirmRevertSubmissionDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction('/revert_submission', this.actions.revert_submission,
-        false, {message: el.message});
-  }
-
-  _handleAbandonDialogConfirm() {
-    const el = this.$.confirmAbandonDialog;
-    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 => action.type === type && action.key === key);
-  }
-
-  _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;
-    }
-
-    // If the action appears in the overflow menu.
-    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
-      this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
-        buttonKey);
-      return function() {
-        this._actionLoadingMessage = '';
-        this._disabledMenuActions = [];
-      }.bind(this);
-    }
-
-    // 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);
-  }
-
-  /**
-   * @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);
-
-    this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
-        action).then(this._handleResponse.bind(this, action));
-  }
-
-  _showActionDialog(dialog) {
-    this._hideAllDialogs();
-
-    dialog.hidden = false;
-    this.$.overlay.open().then(() => {
-      if (dialog.resetFocus) {
-        dialog.resetFocus();
-      }
-    });
-  }
-
-  // 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(() => {
-                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.',
-                action: 'Reload',
-                callback: () => {
-                // 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;
-              });
-        });
-  }
-
-  _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;
-  }
-
-  /**
-   * 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;
-    }
-  }
-
-  _shouldSkipAction(action) {
-    return SKIP_ACTION_KEYS.includes(action.__key);
-  }
-
-  _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));
-    });
-  }
-
-  _filterPrimaryActions(_topLevelActions) {
-    this._topLevelPrimaryActions = _topLevelActions.filter(action =>
-      action.__primary);
-    this._topLevelSecondaryActions = _topLevelActions.filter(action =>
-      !action.__primary);
-  }
-
-  _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,
-      };
-    });
-  }
-
-  _computeRebaseOnCurrent(revisionRebaseAction) {
-    if (revisionRebaseAction) {
-      return !!revisionRebaseAction.enabled;
-    }
-    return null;
-  }
-
-  /**
-   * 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;
-          }
-
-          if (attempsRemaining) {
-            this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
-          } else {
-            resolve(false);
-          }
-        });
-      };
-      check();
-    });
-  }
-
-  _handleEditTap() {
-    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
-  }
-
-  _handleStopEditTap() {
-    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
-  }
-
-  _computeHasTooltip(title) {
-    return !!title;
-  }
-
-  _computeHasIcon(action) {
-    return action.icon ? '' : 'hidden';
-  }
-}
-
-customElements.define(GrChangeActions.is, GrChangeActions);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
new file mode 100644
index 0000000..3f6dd23
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -0,0 +1,2102 @@
+/**
+ * @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 '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-actions_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {
+  fetchChangeUpdates,
+  patchNumEquals,
+} from '../../../utils/patch-set-util';
+import {
+  changeIsOpen,
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../../utils/change-util';
+import {
+  ChangeStatus,
+  DraftsAction,
+  HttpMethod,
+  NotifyType,
+} from '../../../constants/constants';
+import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {customElement, observe, property} from '@polymer/decorators';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {
+  ActionPriority,
+  ActionType,
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ActionInfo,
+  ActionNameToActionInfoMap,
+  BranchName,
+  ChangeInfo,
+  ChangeViewChangeInfo,
+  CherryPickInput,
+  CommitId,
+  InheritedBooleanInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+  NumericChangeId,
+  PatchSetNum,
+  PropertyType,
+  RequestPayload,
+  RevertSubmissionInfo,
+  ReviewInput,
+  ServerInfo,
+} from '../../../types/common';
+import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import {GrConfirmRevertSubmissionDialog} from '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import {
+  ConfirmRevertEventDetail,
+  GrConfirmRevertDialog,
+  RevertType,
+} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import {GrConfirmCherrypickConflictDialog} from '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import {
+  ConfirmRebaseEventDetail,
+  GrConfirmRebaseDialog,
+} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {
+  ChangeActions,
+  GrChangeActionsElement,
+  PrimaryActionKey,
+  RevisionActions,
+  UIActionInfo,
+} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+
+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 LabelStatus {
+  /**
+   * This label provides what is necessary for submission.
+   */
+  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 ActionLoadingLabels: {[actionKey: string]: string} = {
+  abandon: 'Abandoning...',
+  cherrypick: 'Cherry-picking...',
+  delete: 'Deleting...',
+  move: 'Moving..',
+  rebase: 'Rebasing...',
+  restore: 'Restoring...',
+  revert: 'Reverting...',
+  revert_submission: 'Reverting Submission...',
+  submit: 'Submitting...',
+};
+
+const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+
+interface QuickApproveUIActionInfo extends UIActionInfo {
+  key: string;
+  payload?: RequestPayload;
+}
+
+const QUICK_APPROVE_ACTION: QuickApproveUIActionInfo = {
+  __key: 'review',
+  __type: ActionType.CHANGE,
+  enabled: true,
+  key: 'review',
+  label: 'Quick approve',
+  method: HttpMethod.POST,
+};
+
+function isQuckApproveAction(
+  action: UIActionInfo
+): action is QuickApproveUIActionInfo {
+  return (action as QuickApproveUIActionInfo).key === QUICK_APPROVE_ACTION.key;
+}
+
+const DOWNLOAD_ACTION: UIActionInfo = {
+  enabled: true,
+  label: 'Download patch',
+  title: 'Open download dialog',
+  __key: 'download',
+  __primary: false,
+  __type: ActionType.REVISION,
+};
+
+const REBASE_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Rebase edit',
+  title: 'Rebase change edit',
+  __key: 'rebaseEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+  method: HttpMethod.POST,
+};
+
+const PUBLISH_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Publish edit',
+  title: 'Publish change edit',
+  __key: 'publishEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+  method: HttpMethod.POST,
+};
+
+const DELETE_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Delete edit',
+  title: 'Delete change edit',
+  __key: 'deleteEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+  method: HttpMethod.DELETE,
+};
+
+const EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Edit',
+  title: 'Edit this change',
+  __key: 'edit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+};
+
+const STOP_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Stop editing',
+  title: 'Stop editing this change',
+  __key: 'stopEdit',
+  __primary: false,
+  __type: ActionType.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 EDIT_ACTIONS: Set<string> = new Set([
+  ChangeActions.DELETE_EDIT,
+  ChangeActions.EDIT,
+  ChangeActions.PUBLISH_EDIT,
+  ChangeActions.REBASE_EDIT,
+  ChangeActions.STOP_EDIT,
+]);
+
+const AWAIT_CHANGE_ATTEMPTS = 5;
+const AWAIT_CHANGE_TIMEOUT_MS = 1000;
+
+/* Revert submission is skipped as the normal revert dialog will now show
+the user a choice between reverting single change or an entire submission.
+Hence, a second button is not needed.
+*/
+const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
+
+const SKIP_ACTION_KEYS_ATTENTION_SET = [
+  ChangeActions.REVIEWED,
+  ChangeActions.UNREVIEWED,
+];
+
+function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
+  // TODO(TS): Remove this function. The gr-change-actions adds properties
+  // to existing ActionInfo objects instead of creating a new objects. This
+  // function checks, that 'action' has all property required by UIActionInfo.
+  // In the future, we should avoid updates of an existing ActionInfos and
+  // instead create a new object to make code cleaner. However, at the current
+  // state this is unsafe, because other code can expect these properties to be
+  // set in ActionInfo.
+  if (!action) {
+    throw new Error('action is undefined');
+  }
+  const result = action as UIActionInfo;
+  if (result.__key === undefined || result.__type === undefined) {
+    throw new Error('action is not an UIActionInfo');
+  }
+  return result;
+}
+
+interface MenuAction {
+  name: string;
+  id: string;
+  action: UIActionInfo;
+  tooltip?: string;
+}
+
+interface OverflowAction {
+  type: ActionType;
+  key: string;
+  overflow?: boolean;
+}
+
+interface ActionPriorityOverride {
+  type: ActionType.CHANGE | ActionType.REVISION;
+  key: string;
+  priority: ActionPriority;
+}
+
+interface ChangeActionDialog extends HTMLElement {
+  resetFocus?(): void;
+}
+
+export interface GrChangeActions {
+  $: {
+    jsAPI: GrJsApiInterface;
+    restAPI: RestApiService & Element;
+    mainContent: Element;
+    overlay: GrOverlay;
+    confirmRebase: GrConfirmRebaseDialog;
+    confirmCherrypick: GrConfirmCherrypickDialog;
+    confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
+    confirmMove: GrConfirmMoveDialog;
+    confirmRevertDialog: GrConfirmRevertDialog;
+    confirmRevertSubmissionDialog: GrConfirmRevertSubmissionDialog;
+    confirmAbandonDialog: GrConfirmAbandonDialog;
+    confirmSubmitDialog: GrConfirmSubmitDialog;
+    createFollowUpDialog: GrDialog;
+    createFollowUpChange: GrCreateChangeDialog;
+    confirmDeleteDialog: GrDialog;
+    confirmDeleteEditDialog: GrDialog;
+  };
+}
+
+@customElement('gr-change-actions')
+export class GrChangeActions
+  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  implements GrChangeActionsElement {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the change should be reloaded.
+   *
+   * @event reload
+   */
+
+  /**
+   * 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
+   */
+
+  // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
+  // properties are replaced with enums everywhere and remove them from
+  // the GrChangeActions class
+  ActionType = ActionType;
+
+  ChangeActions = ChangeActions;
+
+  RevisionActions = RevisionActions;
+
+  reporting = appContext.reportingService;
+
+  @property({type: Object})
+  change?: ChangeViewChangeInfo;
+
+  @property({type: Object})
+  actions: ActionNameToActionInfoMap = {};
+
+  @property({type: Array})
+  primaryActionKeys: PrimaryActionKey[] = [
+    ChangeActions.READY,
+    RevisionActions.SUBMIT,
+  ];
+
+  @property({type: Boolean})
+  disableEdit = false;
+
+  @property({type: Boolean})
+  _hasKnownChainState = false;
+
+  @property({type: Boolean})
+  _hideQuickApproveAction = false;
+
+  @property({type: String})
+  changeNum?: NumericChangeId;
+
+  @property({type: String})
+  changeStatus?: ChangeStatus;
+
+  @property({type: String})
+  commitNum?: CommitId;
+
+  @property({type: Boolean, observer: '_computeChainState'})
+  hasParent?: boolean;
+
+  @property({type: String})
+  latestPatchNum?: PatchSetNum;
+
+  @property({type: String})
+  commitMessage = '';
+
+  @property({type: Object, notify: true})
+  revisionActions: ActionNameToActionInfoMap = {};
+
+  @property({type: Object, computed: '_getSubmitAction(revisionActions)'})
+  _revisionSubmitAction?: ActionInfo | null;
+
+  @property({type: Object, computed: '_getRebaseAction(revisionActions)'})
+  _revisionRebaseAction?: ActionInfo | null;
+
+  @property({type: String})
+  privateByDefault?: InheritedBooleanInfo;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _actionLoadingMessage = '';
+
+  @property({
+    type: Array,
+    computed:
+      '_computeAllActions(actions.*, revisionActions.*,' +
+      'primaryActionKeys.*, _additionalActions.*, change, ' +
+      '_config, _actionPriorityOverrides.*)',
+  })
+  _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+
+  @property({
+    type: Array,
+    computed:
+      '_computeTopLevelActions(_allActionValues.*, ' +
+      '_hiddenActions.*, editMode, _overflowActions.*)',
+    observer: '_filterPrimaryActions',
+  })
+  _topLevelActions?: UIActionInfo[];
+
+  @property({type: Array})
+  _topLevelPrimaryActions?: UIActionInfo[];
+
+  @property({type: Array})
+  _topLevelSecondaryActions?: UIActionInfo[];
+
+  @property({
+    type: Array,
+    computed:
+      '_computeMenuActions(_allActionValues.*, ' +
+      '_hiddenActions.*, _overflowActions.*)',
+  })
+  _menuActions?: MenuAction[];
+
+  @property({type: Array})
+  _overflowActions: OverflowAction[] = [
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.WIP,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.DELETE,
+    },
+    {
+      type: ActionType.REVISION,
+      key: RevisionActions.CHERRYPICK,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.MOVE,
+    },
+    {
+      type: ActionType.REVISION,
+      key: RevisionActions.DOWNLOAD,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.IGNORE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.UNIGNORE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.REVIEWED,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.UNREVIEWED,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.PRIVATE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.PRIVATE_DELETE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.FOLLOW_UP,
+    },
+  ];
+
+  @property({type: Array})
+  _actionPriorityOverrides: ActionPriorityOverride[] = [];
+
+  @property({type: Array})
+  _additionalActions: UIActionInfo[] = [];
+
+  @property({type: Array})
+  _hiddenActions: string[] = [];
+
+  @property({type: Array})
+  _disabledMenuActions: string[] = [];
+
+  @property({type: Boolean})
+  editPatchsetLoaded = false;
+
+  @property({type: Boolean})
+  editMode = false;
+
+  @property({type: Boolean})
+  editBasedOnCurrentPatchSet = true;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('fullscreen-overlay-opened', () =>
+      this._handleHideBackgroundContent()
+    );
+    this.addEventListener('fullscreen-overlay-closed', () =>
+      this._handleShowBackgroundContent()
+    );
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+    this._handleLoadingComplete();
+  }
+
+  _getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
+    return this._getRevisionAction(revisionActions, 'submit');
+  }
+
+  _getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
+    return this._getRevisionAction(revisionActions, 'rebase');
+  }
+
+  _getRevisionAction(
+    revisionActions: ActionNameToActionInfoMap,
+    actionName: string
+  ) {
+    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 null;
+    }
+    return revisionActions[actionName];
+  }
+
+  reload() {
+    if (!this.changeNum || !this.latestPatchNum || !this.change) {
+      return Promise.resolve();
+    }
+    const change = this.change;
+
+    this._loading = true;
+    return this.$.restAPI
+      .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
+      .then(revisionActions => {
+        if (!revisionActions) {
+          return;
+        }
+
+        this.revisionActions = revisionActions;
+        this._sendShowRevisionActions({
+          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;
+      });
+  }
+
+  _handleLoadingComplete() {
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => (this._loading = false));
+  }
+
+  _sendShowRevisionActions(detail: {
+    change: ChangeInfo;
+    revisionActions: ActionNameToActionInfoMap;
+  }) {
+    this.$.jsAPI.handleEvent(EventType.SHOW_REVISION_ACTIONS, detail);
+  }
+
+  @observe('change')
+  _changeChanged() {
+    this.reload();
+  }
+
+  addActionButton(type: ActionType, label: string) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type: ${type}`);
+    }
+    const action: UIActionInfo = {
+      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: string) {
+    const idx = this._indexOfActionButtonWithKey(key);
+    if (idx === -1) {
+      return;
+    }
+    this.splice('_additionalActions', idx, 1);
+  }
+
+  setActionButtonProp<T extends keyof UIActionInfo>(
+    key: string,
+    prop: T,
+    value: UIActionInfo[T]
+  ) {
+    this.set(
+      ['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
+      value
+    );
+  }
+
+  setActionOverflow(type: ActionType, key: string, overflow: boolean) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+    const index = this._getActionOverflowIndex(type, key);
+    const action: OverflowAction = {
+      type,
+      key,
+      overflow,
+    };
+    if (!overflow && index !== -1) {
+      this.splice('_overflowActions', index, 1);
+    } else if (overflow) {
+      this.push('_overflowActions', action);
+    }
+  }
+
+  setActionPriority(
+    type: ActionType.CHANGE | ActionType.REVISION,
+    key: string,
+    priority: ActionPriority
+  ) {
+    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: ActionPriorityOverride = {
+      type,
+      key,
+      priority,
+    };
+    if (index !== -1) {
+      this.set('_actionPriorityOverrides', index, action);
+    } else {
+      this.push('_actionPriorityOverrides', action);
+    }
+  }
+
+  setActionHidden(
+    type: ActionType.CHANGE | ActionType.REVISION,
+    key: string,
+    hidden: boolean
+  ) {
+    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(actionName: string) {
+    if (this.revisionActions[actionName]) {
+      return this.revisionActions[actionName];
+    } else if (this.actions[actionName]) {
+      return this.actions[actionName];
+    } else {
+      return undefined;
+    }
+  }
+
+  _indexOfActionButtonWithKey(key: string) {
+    for (let i = 0; i < this._additionalActions.length; i++) {
+      if (this._additionalActions[i].__key === key) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  _shouldHideActions(
+    actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+    loading?: boolean
+  ) {
+    return loading || !actions || !actions.base || !actions.base.length;
+  }
+
+  _keyCount(
+    changeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >
+  ) {
+    return Object.keys(changeRecord?.base || {}).length;
+  }
+
+  @observe('actions.*', 'revisionActions.*', '_additionalActions.*')
+  _actionsChanged(
+    actionsChangeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    revisionActionsChangeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    additionalActionsChangeRecord?: PolymerDeepPropertyChange<
+      UIActionInfo[],
+      UIActionInfo[]
+    >
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      actionsChangeRecord === undefined ||
+      revisionActionsChangeRecord === undefined ||
+      additionalActionsChangeRecord === undefined
+    ) {
+      return;
+    }
+
+    const additionalActions =
+      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+      [];
+    this.hidden =
+      this._keyCount(actionsChangeRecord) === 0 &&
+      this._keyCount(revisionActionsChangeRecord) === 0 &&
+      additionalActions.length === 0;
+    this._actionLoadingMessage = '';
+    this._actionLoadingMessage = '';
+    this._disabledMenuActions = [];
+
+    const revisionActions = revisionActionsChangeRecord.base || {};
+    if (Object.keys(revisionActions).length !== 0) {
+      if (!revisionActions.download) {
+        this.set('revisionActions.download', DOWNLOAD_ACTION);
+      }
+    }
+  }
+
+  _deleteAndNotify(actionName: string) {
+    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);
+    }
+  }
+
+  @observe(
+    'editMode',
+    'editPatchsetLoaded',
+    'editBasedOnCurrentPatchSet',
+    'disableEdit',
+    'actions.*',
+    'change.*'
+  )
+  _editStatusChanged(
+    editMode: boolean,
+    editPatchsetLoaded: boolean,
+    editBasedOnCurrentPatchSet: boolean,
+    disableEdit: boolean,
+    actionsChangeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+  ) {
+    if (actionsChangeRecord === undefined || changeChangeRecord === undefined) {
+      return;
+    }
+    if (disableEdit) {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+      this._deleteAndNotify('stopEdit');
+      this._deleteAndNotify('edit');
+      return;
+    }
+    const actions = actionsChangeRecord.base;
+    const change = changeChangeRecord.base;
+    if (actions && editPatchsetLoaded) {
+      // Only show actions that mutate an edit if an actual edit patch set
+      // is loaded.
+      if (changeIsOpen(change)) {
+        if (editBasedOnCurrentPatchSet) {
+          if (!actions.publishEdit) {
+            this.set('actions.publishEdit', PUBLISH_EDIT);
+          }
+          this._deleteAndNotify('rebaseEdit');
+        } else {
+          if (!actions.rebaseEdit) {
+            this.set('actions.rebaseEdit', REBASE_EDIT);
+          }
+          this._deleteAndNotify('publishEdit');
+        }
+      }
+      if (!actions.deleteEdit) {
+        this.set('actions.deleteEdit', DELETE_EDIT);
+      }
+    } else {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+    }
+
+    if (actions && changeIsOpen(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 (!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 (!actions.stopEdit) {
+          this.set('actions.stopEdit', STOP_EDIT);
+        }
+      } else {
+        this._deleteAndNotify('stopEdit');
+      }
+    } else {
+      // Remove edit button.
+      this._deleteAndNotify('edit');
+    }
+  }
+
+  _getValuesFor<T>(obj: {[key: string]: T}): T[] {
+    return Object.keys(obj).map(key => obj[key]);
+  }
+
+  _getLabelStatus(label: LabelInfo): LabelStatus {
+    if (isQuickLabelInfo(label)) {
+      if (label.approved) {
+        return LabelStatus.OK;
+      } else if (label.rejected) {
+        return LabelStatus.REJECT;
+      }
+    }
+    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.
+   */
+  _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;
+      }
+      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
+      ) {
+        return null;
+      }
+    }
+    if (result) {
+      const score = this.change.permitted_labels[result].slice(-1)[0];
+      const labelInfo = this.change.labels[result];
+      if (!isDetailedLabelInfo(labelInfo)) {
+        return null;
+      }
+      const maxScore = Object.keys(labelInfo.values).slice(-1)[0];
+      if (score === maxScore) {
+        // Allow quick approve only for maximal score.
+        return {
+          label: result,
+          score,
+        };
+      }
+    }
+    return null;
+  }
+
+  hideQuickApproveAction() {
+    if (!this._topLevelSecondaryActions) {
+      throw new Error('_topLevelSecondaryActions must be set');
+    }
+    this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
+      sa => !isQuckApproveAction(sa)
+    );
+    this._hideQuickApproveAction = true;
+  }
+
+  _getQuickApproveAction(): QuickApproveUIActionInfo | null {
+    if (this._hideQuickApproveAction) {
+      return null;
+    }
+    const approval = this._getTopMissingApproval();
+    if (!approval) {
+      return null;
+    }
+    const action = {...QUICK_APPROVE_ACTION};
+    action.label = approval.label + approval.score;
+
+    const score = Number(approval.score);
+    if (isNaN(score)) {
+      return null;
+    }
+
+    const review: ReviewInput = {
+      drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
+      labels: {
+        [approval.label]: score,
+      },
+    };
+    action.payload = review;
+    return action;
+  }
+
+  _getActionValues(
+    actionsChangeRecord: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    primariesChangeRecord: PolymerDeepPropertyChange<
+      PrimaryActionKey[],
+      PrimaryActionKey[]
+    >,
+    additionalActionsChangeRecord: PolymerDeepPropertyChange<
+      UIActionInfo[],
+      UIActionInfo[]
+    >,
+    type: ActionType
+  ): UIActionInfo[] {
+    if (!actionsChangeRecord || !primariesChangeRecord) {
+      return [];
+    }
+
+    const actions = actionsChangeRecord.base || {};
+    const primaryActionKeys = primariesChangeRecord.base || [];
+    const result: UIActionInfo[] = [];
+    const values: Array<ChangeActions | RevisionActions> =
+      type === ActionType.CHANGE
+        ? this._getValuesFor(ChangeActions)
+        : this._getValuesFor(RevisionActions);
+
+    const pluginActions: UIActionInfo[] = [];
+    Object.keys(actions).forEach(a => {
+      const action: UIActionInfo = actions[a] as UIActionInfo;
+      action.__key = a;
+      action.__type = type;
+      action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
+      // Plugin actions always contain ~ in the key.
+      if (a.indexOf('~') !== -1) {
+        this._populateActionUrl(action);
+        pluginActions.push(action);
+        // Add server-side provided plugin actions to overflow menu.
+        this._overflowActions.push({
+          type,
+          key: a,
+        });
+        return;
+      } else if (!values.includes(a as PrimaryActionKey)) {
+        return;
+      }
+      action.label = this._getActionLabel(action);
+
+      // Triggers a re-render by ensuring object inequality.
+      result.push({...action});
+    });
+
+    let additionalActions =
+      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+      [];
+    additionalActions = additionalActions
+      .filter(a => a.__type === type)
+      .map(a => {
+        a.__primary = primaryActionKeys.includes(a.__key as PrimaryActionKey);
+        // Triggers a re-render by ensuring object inequality.
+        return {...a};
+      });
+    return result.concat(additionalActions).concat(pluginActions);
+  }
+
+  _populateActionUrl(action: UIActionInfo) {
+    const patchNum =
+      action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
+    if (!this.changeNum) {
+      return;
+    }
+    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: UIActionInfo) {
+    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.
+   */
+  _toSentenceCase(s: string) {
+    if (!s.length) {
+      return '';
+    }
+    return s[0].toUpperCase() + s.slice(1).toLowerCase();
+  }
+
+  _computeLoadingLabel(action: string) {
+    return ActionLoadingLabels[action] || 'Working...';
+  }
+
+  _canSubmitChange() {
+    if (!this.change) {
+      return false;
+    }
+    return this.$.jsAPI.canSubmitChange(
+      this.change,
+      this._getRevision(this.change, this.latestPatchNum)
+    );
+  }
+
+  _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+    for (const rev of Object.values(change.revisions)) {
+      if (patchNumEquals(rev._number, patchNum)) {
+        return rev;
+      }
+    }
+    return null;
+  }
+
+  showRevertDialog() {
+    const change = this.change;
+    if (!change) return;
+    // The search is still broken if there is a " in the topic.
+    const query = `submissionid: "${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(0, query).then(changes => {
+      if (!changes) {
+        console.error('changes is undefined');
+        return;
+      }
+      this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
+      this._showActionDialog(this.$.confirmRevertDialog);
+    });
+  }
+
+  showRevertSubmissionDialog() {
+    const change = this.change;
+    if (!change) return;
+    const query = `submissionid:${change.submission_id}`;
+    this.$.restAPI.getChanges(0, query).then(changes => {
+      if (!changes) {
+        console.error('changes is undefined');
+        return;
+      }
+      this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
+        change,
+        changes
+      );
+      this._showActionDialog(this.$.confirmRevertSubmissionDialog);
+    });
+  }
+
+  _handleActionTap(e: MouseEvent) {
+    e.preventDefault();
+    let el = (dom(e) as EventApi).localTarget as Element;
+    while (el.tagName.toLowerCase() !== 'gr-button') {
+      if (!el.parentElement) {
+        return;
+      }
+      el = el.parentElement;
+    }
+
+    const key = el.getAttribute('data-action-key');
+    if (!key) {
+      throw new Error("Button doesn't have data-action-key attribute");
+    }
+    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') as ActionType;
+    this._handleAction(type, key);
+  }
+
+  _handleOverflowItemTap(e: CustomEvent<MenuAction>) {
+    e.preventDefault();
+    const el = (dom(e) as EventApi).localTarget as Element;
+    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: ActionType, key: string) {
+    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),
+          assertUIActionInfo(this.actions[key]),
+          false
+        );
+    }
+  }
+
+  _handleChangeAction(key: string) {
+    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: {
+        const action = this._allActionValues.find(isQuckApproveAction);
+        if (!action) {
+          return;
+        }
+        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),
+          assertUIActionInfo(this.actions[key]),
+          false
+        );
+    }
+  }
+
+  _handleRevisionAction(key: string) {
+    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),
+          assertUIActionInfo(this.revisionActions[key]),
+          true
+        );
+    }
+  }
+
+  _prependSlash(key: string) {
+    return key === '/' ? key : `/${key}`;
+  }
+
+  /**
+   * _hasKnownChainState set to true true if hasParent is defined (can be
+   * either true or false). set to false otherwise.
+   */
+  _computeChainState() {
+    this._hasKnownChainState = true;
+  }
+
+  _calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
+    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 = this.root!.querySelectorAll('.confirmDialog');
+    for (const dialogEl of dialogEls) {
+      (dialogEl as HTMLElement).hidden = true;
+    }
+    this.$.overlay.close();
+  }
+
+  _handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
+    const el = this.$.confirmRebase;
+    const payload = {base: e.detail.base};
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/rebase',
+      assertUIActionInfo(this.revisionActions.rebase),
+      true,
+      payload
+    );
+  }
+
+  _handleCherrypickConfirm() {
+    this._handleCherryPickRestApi(false);
+  }
+
+  _handleCherrypickConflictConfirm() {
+    this._handleCherryPickRestApi(true);
+  }
+
+  _handleCherryPickRestApi(conflicts: boolean) {
+    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',
+      assertUIActionInfo(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.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: ERR_BRANCH_EMPTY},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
+      destination_branch: el.branch,
+      message: el.message,
+    });
+  }
+
+  _handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+    const revertType = e.detail.revertType;
+    const message = e.detail.message;
+    const el = this.$.confirmRevertDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    switch (revertType) {
+      case RevertType.REVERT_SINGLE_CHANGE:
+        this._fireAction(
+          '/revert',
+          assertUIActionInfo(this.actions.revert),
+          false,
+          {message}
+        );
+        break;
+      case RevertType.REVERT_SUBMISSION:
+        this._fireAction(
+          '/revert_submission',
+          assertUIActionInfo(this.actions.revert_submission),
+          false,
+          {message}
+        );
+        break;
+      default:
+        console.error('invalid revert type');
+    }
+  }
+
+  _handleRevertSubmissionDialogConfirm() {
+    const el = this.$.confirmRevertSubmissionDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/revert_submission',
+      assertUIActionInfo(this.actions.revert_submission),
+      false,
+      {message: el.message}
+    );
+  }
+
+  _handleAbandonDialogConfirm() {
+    const el = this.$.confirmAbandonDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/abandon',
+      assertUIActionInfo(this.actions.abandon),
+      false,
+      {
+        message: el.message,
+      }
+    );
+  }
+
+  _handleCreateFollowUpChange() {
+    this.$.createFollowUpChange.handleCreateChange();
+    this._handleCloseCreateFollowUpChange();
+  }
+
+  _handleCloseCreateFollowUpChange() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteConfirm() {
+    this._fireAction(
+      '/',
+      assertUIActionInfo(this.actions[ChangeActions.DELETE]),
+      false
+    );
+  }
+
+  _handleDeleteEditConfirm() {
+    this._hideAllDialogs();
+
+    this._fireAction(
+      '/edit',
+      assertUIActionInfo(this.actions.deleteEdit),
+      false
+    );
+  }
+
+  _handleSubmitConfirm() {
+    if (!this._canSubmitChange()) {
+      return;
+    }
+    this._hideAllDialogs();
+    this._fireAction(
+      '/submit',
+      assertUIActionInfo(this.revisionActions.submit),
+      true
+    );
+  }
+
+  _getActionOverflowIndex(type: string, key: string) {
+    return this._overflowActions.findIndex(
+      action => action.type === type && action.key === key
+    );
+  }
+
+  _setLoadingOnButtonWithKey(type: string, key: string) {
+    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;
+    }
+
+    // If the action appears in the overflow menu.
+    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
+      this.push(
+        '_disabledMenuActions',
+        buttonKey === '/' ? 'delete' : buttonKey
+      );
+      return () => {
+        this._actionLoadingMessage = '';
+        this._disabledMenuActions = [];
+      };
+    }
+
+    // Otherwise it's a top-level action.
+    const buttonEl = this.shadowRoot!.querySelector(
+      `[data-action-key="${buttonKey}"]`
+    ) as GrButton;
+    if (!buttonEl) {
+      throw new Error(`Can't find button by data-action-key '${buttonKey}'`);
+    }
+    buttonEl.setAttribute('loading', 'true');
+    buttonEl.disabled = true;
+    return () => {
+      this._actionLoadingMessage = '';
+      buttonEl.removeAttribute('loading');
+      buttonEl.disabled = false;
+    };
+  }
+
+  _fireAction(
+    endpoint: string,
+    action: UIActionInfo,
+    revAction: boolean,
+    payload?: RequestPayload
+  ) {
+    const cleanupFn = this._setLoadingOnButtonWithKey(
+      action.__type,
+      action.__key
+    );
+
+    this._send(
+      action.method,
+      payload,
+      endpoint,
+      revAction,
+      cleanupFn,
+      action
+    ).then(res => this._handleResponse(action, res));
+  }
+
+  _showActionDialog(dialog: ChangeActionDialog) {
+    this._hideAllDialogs();
+
+    dialog.hidden = false;
+    this.$.overlay.open().then(() => {
+      if (dialog.resetFocus) {
+        dialog.resetFocus();
+      }
+    });
+  }
+
+  // TODO(rmistry): Redo this after
+  // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
+  _setLabelValuesOnRevert(newChangeId: NumericChangeId) {
+    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+    if (!labels) {
+      return Promise.resolve(undefined);
+    }
+    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+  }
+
+  _handleResponse(action: UIActionInfo, response?: Response) {
+    if (!response) {
+      return;
+    }
+    return this.$.restAPI.getResponseObject(response).then(obj => {
+      switch (action.__key) {
+        case ChangeActions.REVERT: {
+          const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+          this._waitForChangeReachable(revertChangeInfo._number)
+            .then(() => this._setLabelValuesOnRevert(revertChangeInfo._number))
+            .then(() => {
+              GerritNav.navigateToChange(revertChangeInfo);
+            });
+          break;
+        }
+        case RevisionActions.CHERRYPICK: {
+          const cherrypickChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+          this._waitForChangeReachable(cherrypickChangeInfo._number).then(
+            () => {
+              GerritNav.navigateToChange(cherrypickChangeInfo);
+            }
+          );
+          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:
+        case ChangeActions.REBASE:
+        case ChangeActions.SUBMIT:
+          this.dispatchEvent(
+            new CustomEvent('reload', {
+              detail: {clearPatchset: true},
+              bubbles: false,
+              composed: true,
+            })
+          );
+          break;
+        case ChangeActions.REVERT_SUBMISSION: {
+          const revertSubmistionInfo = (obj as unknown) as RevertSubmissionInfo;
+          if (
+            !revertSubmistionInfo.revert_changes ||
+            !revertSubmistionInfo.revert_changes.length
+          )
+            return;
+          /* If there is only 1 change then gerrit will automatically
+             redirect to that change */
+          GerritNav.navigateToSearchQuery(
+            `topic: ${revertSubmistionInfo.revert_changes[0].topic}`
+          );
+          break;
+        }
+        default:
+          this.dispatchEvent(
+            new CustomEvent('reload', {
+              detail: {action: action.__key, clearPatchset: true},
+              bubbles: false,
+              composed: true,
+            })
+          );
+          break;
+      }
+    });
+  }
+
+  _handleShowRevertSubmissionChangesConfirm() {
+    this._hideAllDialogs();
+  }
+
+  _handleResponseError(
+    action: UIActionInfo,
+    response: Response | undefined | null,
+    body?: RequestPayload
+  ) {
+    if (!response) {
+      return Promise.resolve(() => {
+        this.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message: `Could not perform action '${action.__key}'`},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
+    }
+    if (action && action.__key === RevisionActions.CHERRYPICK) {
+      if (
+        response.status === 409 &&
+        body &&
+        !(body as CherryPickInput).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);
+      }
+    });
+  }
+
+  _send(
+    method: HttpMethod | undefined,
+    payload: RequestPayload | undefined,
+    actionEndpoint: string,
+    revisionAction: boolean,
+    cleanupFn: () => void,
+    action: UIActionInfo
+  ): Promise<Response | undefined> {
+    const handleError: ErrorCallback = response => {
+      cleanupFn.call(this);
+      this._handleResponseError(action, response, payload);
+    };
+    const change = this.change;
+    const changeNum = this.changeNum;
+    if (!change || !changeNum) {
+      return Promise.reject(
+        new Error('Properties change and changeNum must be set.')
+      );
+    }
+    return fetchChangeUpdates(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.',
+              action: 'Reload',
+              callback: () => {
+                this.dispatchEvent(
+                  new CustomEvent('reload', {
+                    detail: {clearPatchset: true},
+                    bubbles: false,
+                    composed: true,
+                  })
+                );
+              },
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+
+        // Because this is not a network error, call the cleanup function
+        // but not the error handler.
+        cleanupFn();
+
+        return Promise.resolve(undefined);
+      }
+      const patchNum = revisionAction ? this.latestPatchNum : undefined;
+      return this.$.restAPI
+        .executeChangeAction(
+          changeNum,
+          method,
+          actionEndpoint,
+          patchNum,
+          payload,
+          handleError
+        )
+        .then(response => {
+          cleanupFn.call(this);
+          return response;
+        });
+    });
+  }
+
+  _handleAbandonTap() {
+    this._showActionDialog(this.$.confirmAbandonDialog);
+  }
+
+  _handleCherrypickTap() {
+    if (!this.change) {
+      throw new Error('The change property must be set');
+    }
+    this.$.confirmCherrypick.branch = '' as BranchName;
+    const query = `topic: "${this.change.topic}"`;
+    const options = listChangesOptionsToHex(
+      ListChangesOption.MESSAGES,
+      ListChangesOption.ALL_REVISIONS
+    );
+    this.$.restAPI.getChanges(0, query, undefined, options).then(changes => {
+      if (!changes) {
+        console.error('getChanges returns undefined');
+        return;
+      }
+      this.$.confirmCherrypick.updateChanges(changes);
+      this._showActionDialog(this.$.confirmCherrypick);
+    });
+  }
+
+  _handleMoveTap() {
+    this.$.confirmMove.branch = '' as BranchName;
+    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() {
+    if (!this.actions.wip) {
+      return;
+    }
+    this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
+  }
+
+  _handlePublishEditTap() {
+    if (!this.actions.publishEdit) {
+      return;
+    }
+    this._fireAction(
+      '/edit:publish',
+      assertUIActionInfo(this.actions.publishEdit),
+      false,
+      {notify: NotifyType.NONE}
+    );
+  }
+
+  _handleRebaseEditTap() {
+    if (!this.actions.rebaseEdit) {
+      return;
+    }
+    this._fireAction(
+      '/edit:rebase',
+      assertUIActionInfo(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.
+   */
+  _computeAllActions(
+    changeActionsRecord: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    revisionActionsRecord: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    primariesRecord: PolymerDeepPropertyChange<
+      PrimaryActionKey[],
+      PrimaryActionKey[]
+    >,
+    additionalActionsRecord: PolymerDeepPropertyChange<
+      UIActionInfo[],
+      UIActionInfo[]
+    >,
+    change?: ChangeInfo,
+    config?: ServerInfo
+  ): UIActionInfo[] {
+    // Polymer 2: check for undefined
+    if (
+      [
+        changeActionsRecord,
+        revisionActionsRecord,
+        primariesRecord,
+        additionalActionsRecord,
+        change,
+      ].includes(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((a, b) => this._actionComparator(a, b))
+      .map(action => {
+        if (ACTIONS_WITH_ICONS.has(action.__key)) {
+          action.icon = action.__key;
+        }
+        // TODO(brohlfs): Temporary hack until change 269573 is live in all
+        // backends.
+        if (action.__key === ChangeActions.READY) {
+          action.label = 'Mark as Active';
+        }
+        // End of hack
+        return action;
+      })
+      .filter(action => !this._shouldSkipAction(action, config));
+  }
+
+  _getActionPriority(action: UIActionInfo) {
+    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;
+  }
+
+  /**
+   * Sort comparator to define the order of change actions.
+   */
+  _actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
+    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;
+    }
+  }
+
+  _shouldSkipAction(action: UIActionInfo, config?: ServerInfo) {
+    const skipActionKeys: string[] = [...SKIP_ACTION_KEYS];
+    const isAttentionSetEnabled =
+      !!config && !!config.change && config.change.enable_attention_set;
+    if (isAttentionSetEnabled) {
+      skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
+    }
+    return skipActionKeys.includes(action.__key);
+  }
+
+  _computeTopLevelActions(
+    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>,
+    editMode: boolean
+  ): UIActionInfo[] {
+    const hiddenActions = hiddenActionsRecord.base || [];
+    return actionRecord.base.filter(a => {
+      if (hiddenActions.includes(a.__key)) return false;
+      if (editMode) return EDIT_ACTIONS.has(a.__key);
+      return this._getActionOverflowIndex(a.__type, a.__key) === -1;
+    });
+  }
+
+  _filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
+    this._topLevelPrimaryActions = _topLevelActions.filter(
+      action => action.__primary
+    );
+    this._topLevelSecondaryActions = _topLevelActions.filter(
+      action => !action.__primary
+    );
+  }
+
+  _computeMenuActions(
+    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+  ): MenuAction[] {
+    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,
+        };
+      });
+  }
+
+  _computeRebaseOnCurrent(
+    revisionRebaseAction: PropertyType<GrChangeActions, '_revisionRebaseAction'>
+  ) {
+    if (revisionRebaseAction) {
+      return !!revisionRebaseAction.enabled;
+    }
+    return null;
+  }
+
+  /**
+   * Occasionally, a change created by a change action is not yet known 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.
+   *
+   */
+  _waitForChangeReachable(changeNum: NumericChangeId) {
+    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;
+            }
+
+            if (attempsRemaining) {
+              this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+            } else {
+              resolve(false);
+            }
+          });
+      };
+      check();
+    });
+  }
+
+  _handleEditTap() {
+    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+  }
+
+  _handleStopEditTap() {
+    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+  }
+
+  _computeHasTooltip(title?: string) {
+    return !!title;
+  }
+
+  _computeHasIcon(action: UIActionInfo) {
+    return action.icon ? '' : 'hidden';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-actions': 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
deleted file mode 100644
index f12e600..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
+++ /dev/null
@@ -1,275 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
new file mode 100644
index 0000000..4e315af
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -0,0 +1,274 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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=""
+      vertical-offset="32"
+      horizontal-align="right"
+      on-tap-item="_handleOverflowItemTap"
+      hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
+      disabled-ids="[[_disabledMenuActions]]"
+      items="[[_menuActions]]"
+    >
+      <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+      </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>
+`;
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
deleted file mode 100644
index acf17ea..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ /dev/null
@@ -1,2041 +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">
-<meta charset="utf-8">
-<title>gr-change-actions</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-change-actions></gr-change-actions>
-  </template>
-</test-fixture>
-
-<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() {
-          return Promise.resolve({
-            cherrypick: {
-              method: 'POST',
-              label: 'Cherry Pick',
-              title: 'Cherry pick change to a different branch',
-              enabled: true,
-            },
-            rebase: {
-              method: 'POST',
-              label: 'Rebase',
-              title: 'Rebase onto tip of branch or parent change',
-              enabled: true,
-            },
-            submit: {
-              method: 'POST',
-              label: 'Submit',
-              title: 'Submit patch set 2 into master',
-              enabled: true,
-            },
-            revert_submission: {
-              method: 'POST',
-              label: 'Revert submission',
-              title: 'Revert this submission',
-              enabled: true,
-            },
-          });
-        },
-        send(method, url, payload) {
-          if (method !== 'POST') {
-            return Promise.reject(new Error('bad method'));
-          }
-
-          if (url === '/changes/test~42/revisions/2/submit') {
-            return Promise.resolve({
-              ok: true,
-              text() { return Promise.resolve(')]}\'\n{}'); },
-            });
-          } else if (url === '/changes/test~42/revisions/2/rebase') {
-            return Promise.resolve({
-              ok: true,
-              text() { return Promise.resolve(')]}\'\n{}'); },
-            });
-          }
-
-          return Promise.reject(new Error('bad url'));
-        },
-        getProjectConfig() { return Promise.resolve({}); },
-      });
-
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
-          .returns(Promise.resolve());
-
-      element = fixture('basic');
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-      element.actions = {
-        '/': {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        },
-      };
-      sandbox.stub(element.$.confirmCherrypick.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      sandbox.stub(element.$.confirmMove.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-
-      return element.reload();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('show-revision-actions event should fire', done => {
-      const spy = sinon.spy(element, '_sendShowRevisionActions');
-      element.reload();
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
-    });
-
-    test('primary and secondary actions split properly', () => {
-      // Submit should be the only primary action.
-      assert.equal(element._topLevelPrimaryActions.length, 1);
-      assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
-      assert.equal(element._topLevelSecondaryActions.length,
-          element._topLevelActions.length - 1);
-    });
-
-    test('revert submission action is skipped', () => {
-      assert.isFalse(element._allActionValues.includes(action =>
-        action.key === 'revert_submission'));
-    });
-
-    test('_shouldHideActions', () => {
-      assert.isTrue(element._shouldHideActions(undefined, true));
-      assert.isTrue(element._shouldHideActions({base: {}}, false));
-      assert.isFalse(element._shouldHideActions({base: ['test']}, false));
-    });
-
-    test('plugin revision actions', done => {
-      sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.revisionActions = {
-        'plugin~action': {},
-      };
-      assert.isOk(element.revisionActions['plugin~action']);
-      flush(() => {
-        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
-            element.changeNum, element.latestPatchNum, '/plugin~action'));
-        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
-        done();
-      });
-    });
-
-    test('plugin change actions', done => {
-      sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.actions = {
-        'plugin~action': {},
-      };
-      assert.isOk(element.actions['plugin~action']);
-      flush(() => {
-        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
-            element.changeNum, null, '/plugin~action'));
-        assert.equal(element.actions['plugin~action'].__url, 'the-url');
-        done();
-      });
-    });
-
-    test('not supported actions are filtered out', () => {
-      element.revisionActions = {followup: {}};
-      assert.equal(element.querySelectorAll(
-          'section gr-button[data-action-type="revision"]').length, 0);
-    });
-
-    test('getActionDetails', () => {
-      element.revisionActions = Object.assign({
-        'plugin~action': {},
-      }, element.revisionActions);
-      assert.isUndefined(element.getActionDetails('rubbish'));
-      assert.strictEqual(element.revisionActions['plugin~action'],
-          element.getActionDetails('plugin~action'));
-      assert.strictEqual(element.revisionActions['rebase'],
-          element.getActionDetails('rebase'));
-    });
-
-    test('hide revision action', done => {
-      flush(() => {
-        const buttonEl = element.shadowRoot
-            .querySelector('[data-action-key="submit"]');
-        assert.isOk(buttonEl);
-        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
-        element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        flush(() => {
-          const buttonEl = element.shadowRoot
-              .querySelector('[data-action-key="submit"]');
-          assert.isNotOk(buttonEl);
-
-          element.setActionHidden(element.ActionType.REVISION,
-              element.RevisionActions.SUBMIT, false);
-          flush(() => {
-            const buttonEl = element.shadowRoot
-                .querySelector('[data-action-key="submit"]');
-            assert.isOk(buttonEl);
-            assert.isFalse(buttonEl.hasAttribute('hidden'));
-            done();
-          });
-        });
-      });
-    });
-
-    test('buttons exist', done => {
-      element._loading = false;
-      flush(() => {
-        const buttonEls = dom(element.root)
-            .querySelectorAll('gr-button');
-        const menuItems = element.$.moreActions.items;
-
-        // Total button number is one greater than the number of total actions
-        // due to the existence of the overflow menu trigger.
-        assert.equal(buttonEls.length + menuItems.length,
-            element._allActionValues.length + 1);
-        assert.isFalse(element.hidden);
-        done();
-      });
-    });
-
-    test('delete buttons have explicit labels', done => {
-      flush(() => {
-        const deleteItems = element.$.moreActions.items
-            .filter(item => item.id.startsWith('delete'));
-        assert.equal(deleteItems.length, 1);
-        assert.notEqual(deleteItems[0].name);
-        assert.equal(deleteItems[0].name, 'Delete change');
-        done();
-      });
-    });
-
-    test('get revision object from change', () => {
-      const revObj = {_number: 2, foo: 'bar'};
-      const change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: revObj,
-        },
-      };
-      assert.deepEqual(element._getRevision(change, '2'), revObj);
-    });
-
-    test('_actionComparator sort order', () => {
-      const actions = [
-        {label: '123', __type: 'change', __key: 'review'},
-        {label: 'abc-ro', __type: 'revision'},
-        {label: 'abc', __type: 'change'},
-        {label: 'def', __type: 'change'},
-        {label: 'def-p', __type: 'change', __primary: true},
-      ];
-
-      const result = actions.slice();
-      result.reverse();
-      result.sort(element._actionComparator.bind(element));
-      assert.deepEqual(result, actions);
-    });
-
-    test('submit change', () => {
-      const showSpy = sandbox.spy(element, '_showActionDialog');
-      sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sandbox.stub(element, 'fetchChangeUpdates',
-          () => Promise.resolve({isLatest: true}));
-      sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="submit"]');
-      assert.ok(submitButton);
-      MockInteractions.tap(submitButton);
-
-      flushAsynchronousOperations();
-      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
-    });
-
-    test('submit change, tap on icon', done => {
-      sandbox.stub(element.$.confirmSubmitDialog, 'resetFocus', done);
-      sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sandbox.stub(element, 'fetchChangeUpdates',
-          () => Promise.resolve({isLatest: true}));
-      sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitIcon =
-          element.shadowRoot
-              .querySelector('gr-button[data-action-key="submit"] iron-icon');
-      assert.ok(submitIcon);
-      MockInteractions.tap(submitIcon);
-    });
-
-    test('_handleSubmitConfirm', () => {
-      const fireStub = sandbox.stub(element, '_fireAction');
-      sandbox.stub(element, '_canSubmitChange').returns(true);
-      element._handleSubmitConfirm();
-      assert.isTrue(fireStub.calledOnce);
-      assert.deepEqual(fireStub.lastCall.args,
-          ['/submit', element.revisionActions.submit, true]);
-    });
-
-    test('_handleSubmitConfirm when not able to submit', () => {
-      const fireStub = sandbox.stub(element, '_fireAction');
-      sandbox.stub(element, '_canSubmitChange').returns(false);
-      element._handleSubmitConfirm();
-      assert.isFalse(fireStub.called);
-    });
-
-    test('submit change with plugin hook', done => {
-      sandbox.stub(element, '_canSubmitChange',
-          () => false);
-      const fireActionStub = sandbox.stub(element, '_fireAction');
-      flush(() => {
-        const submitButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="submit"]');
-        assert.ok(submitButton);
-        MockInteractions.tap(submitButton);
-        assert.equal(fireActionStub.callCount, 0);
-
-        done();
-      });
-    });
-
-    test('chain state', () => {
-      assert.equal(element._hasKnownChainState, false);
-      element.hasParent = true;
-      assert.equal(element._hasKnownChainState, true);
-      element.hasParent = false;
-    });
-
-    test('_calculateDisabled', () => {
-      let hasKnownChainState = false;
-      const action = {__key: 'rebase', enabled: true};
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), true);
-
-      action.__key = 'delete';
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.__key = 'rebase';
-      hasKnownChainState = true;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.enabled = false;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-    });
-
-    test('rebase change', done => {
-      const fireActionStub = sandbox.stub(element, '_fireAction');
-      const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebase"]');
-        MockInteractions.tap(rebaseButton);
-        const rebaseAction = {
-          __key: 'rebase',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Rebase',
-          method: 'POST',
-          title: 'Rebase onto tip of branch or parent change',
-        };
-        assert.isTrue(fetchChangesStub.called);
-        element._handleRebaseConfirm({detail: {base: '1234'}});
-        assert.deepEqual(fireActionStub.lastCall.args,
-            ['/rebase', rebaseAction, true, {base: '1234'}]);
-        done();
-      });
-    });
-
-    test(`rebase dialog gets recent changes each time it's opened`, done => {
-      const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      const rebaseButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="rebase"]');
-      MockInteractions.tap(rebaseButton);
-      assert.isTrue(fetchChangesStub.calledOnce);
-
-      flush(() => {
-        element.$.confirmRebase.dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-        MockInteractions.tap(rebaseButton);
-        assert.isTrue(fetchChangesStub.calledTwice);
-        done();
-      });
-    });
-
-    test('two dialogs are not shown at the same time', done => {
-      element._hasKnownChainState = true;
-      flush(() => {
-        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();
-        flush(() => {
-          assert.isTrue(element.$.confirmRebase.hidden);
-          assert.isFalse(element.$.confirmCherrypick.hidden);
-          done();
-        });
-      });
-    });
-
-    test('fullscreen-overlay-opened hides content', () => {
-      sandbox.spy(element, '_handleHideBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleHideBackgroundContent.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      sandbox.spy(element, '_handleShowBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-closed', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleShowBackgroundContent.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('_setLabelValuesOnRevert', () => {
-      const labels = {'Foo': 1, 'Bar-Baz': -2};
-      const changeId = 1234;
-      sandbox.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
-      const saveStub = sandbox.stub(element.$.restAPI, 'saveChangeReview')
-          .returns(Promise.resolve());
-      return element._setLabelValuesOnRevert(changeId).then(() => {
-        assert.isTrue(saveStub.calledOnce);
-        assert.equal(saveStub.lastCall.args[0], changeId);
-        assert.deepEqual(saveStub.lastCall.args[2], {labels});
-      });
-    });
-
-    suite('change edits', () => {
-      test('disableEdit', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        element.set('disableEdit', true);
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('shows confirm dialog for delete edit', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-
-        const fireActionStub = sandbox.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteEditDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.equal(fireActionStub.lastCall.args[0], '/edit');
-      });
-
-      test('hide publishEdit and rebaseEdit if change is not open', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'MERGED'};
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-      });
-
-      test('edit patchset is loaded, needs rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = false;
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit patchset is loaded, does not need rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = true;
-        flushAsynchronousOperations();
-
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit mode is loaded, no edit patchset', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('normal patch set', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit action', done => {
-        element.addEventListener('edit-tap', () => { done(); });
-        element.set('editMode', true);
-        element.change = {status: 'NEW'};
-        flushAsynchronousOperations();
-
-        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.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        element.change = {status: 'NEW'};
-        element.set('editMode', false);
-        flushAsynchronousOperations();
-
-        const editButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]');
-        assert.isOk(editButton);
-        MockInteractions.tap(editButton);
-      });
-    });
-
-    suite('cherry-pick', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        sandbox.stub(window, 'alert');
-      });
-
-      test('works', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: '',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmCherrypick.branch = 'master';
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-
-        assert.equal(element.$.confirmCherrypick.shadowRoot.
-            querySelector('#messageInput').value, 'foo message');
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: false,
-          },
-        ]);
-      });
-
-      test('cherry pick even with conflicts', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element.$.confirmCherrypick.branch = 'master';
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConflictConfirm();
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: true,
-          },
-        ]);
-      });
-
-      test('branch name cleared when re-open cherrypick', () => {
-        const emptyBranchName = '';
-        element.$.confirmCherrypick.branch = 'master';
-
-        element._handleCherrypickTap();
-        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
-      });
-
-      suite('cherry pick topics', () => {
-        const changes = [
-          {
-            change_id: '12345678901234', topic: 'T', subject: 'random',
-            project: 'A',
-          },
-          {
-            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', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        sandbox.stub(window, 'alert');
-      });
-
-      test('works', () => {
-        element._handleMoveTap();
-
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmMove.branch = 'master';
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 1);
-      });
-
-      test('branch name cleared when re-open move', () => {
-        const emptyBranchName = '';
-        element.$.confirmMove.branch = 'master';
-
-        element._handleMoveTap();
-        assert.equal(element.$.confirmMove.branch, emptyBranchName);
-      });
-    });
-
-    test('custom actions', done => {
-      // Add a button with the same key as a server-based one to ensure
-      // collisions are taken care of.
-      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-      element.addEventListener(key + '-tap', e => {
-        assert.equal(e.detail.node.getAttribute('data-action-key'), key);
-        element.removeActionButton(key);
-        flush(() => {
-          assert.notOk(element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]'));
-          done();
-        });
-      });
-      flush(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]'));
-      });
-    });
-
-    test('_setLoadingOnButtonWithKey top-level', () => {
-      const key = 'rebase';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Rebasing...');
-
-      const button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isTrue(button.hasAttribute('loading'));
-      assert.isTrue(button.disabled);
-
-      assert.isOk(cleanup);
-      assert.isFunction(cleanup);
-      cleanup();
-
-      assert.isFalse(button.hasAttribute('loading'));
-      assert.isFalse(button.disabled);
-      assert.isNotOk(element._actionLoadingMessage);
-    });
-
-    test('_setLoadingOnButtonWithKey overflow menu', () => {
-      const key = 'cherrypick';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
-      assert.include(element._disabledMenuActions, 'cherrypick');
-      assert.isFunction(cleanup);
-
-      cleanup();
-
-      assert.notOk(element._actionLoadingMessage);
-      assert.notInclude(element._disabledMenuActions, 'cherrypick');
-    });
-
-    suite('abandon change', () => {
-      let alertStub;
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        alertStub = sandbox.stub(window, 'alert');
-        element.actions = {
-          abandon: {
-            method: 'POST',
-            label: 'Abandon',
-            title: 'Abandon the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('abandon change with message', done => {
-        const newAbandonMsg = 'Test Abandon Message';
-        element.$.confirmAbandonDialog.message = newAbandonMsg;
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
-          done();
-        });
-      });
-
-      test('abandon change with no message', done => {
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.isUndefined(element.$.confirmAbandonDialog.message);
-          done();
-        });
-      });
-
-      test('works', () => {
-        element.$.confirmAbandonDialog.message = 'original message';
-        const restoreButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key="abandon"]');
-        MockInteractions.tap(restoreButton);
-
-        element.$.confirmAbandonDialog.message = 'foo message';
-        element._handleAbandonDialogConfirm();
-        assert.notOk(alertStub.called);
-
-        const action = {
-          __key: 'abandon',
-          __type: 'change',
-          __primary: false,
-          enabled: true,
-          label: 'Abandon',
-          method: 'POST',
-          title: 'Abandon the change',
-        };
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/abandon', action, false, {
-            message: 'foo message',
-          }]);
-      });
-    });
-
-    suite('revert change', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        element.commitMessage = 'random commit message';
-        element.change.current_revision = 'abcdef';
-        element.actions = {
-          revert: {
-            method: 'POST',
-            label: 'Revert',
-            title: 'Revert the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('revert change with plugin hook', done => {
-        const newRevertMsg = 'Modified revert msg';
-        sandbox.stub(element.$.confirmRevertDialog, '_modifyRevertMsg',
-            () => newRevertMsg);
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        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.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
-            done();
-          });
-        });
-      });
-
-      suite('revert change submitted together', () => {
-        let getChangesStub;
-        setup(() => {
-          element.change = {
-            submission_id: '199 0',
-            current_revision: '2000',
-          };
-          getChangesStub = 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('confirm revert dialog shows both options', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const revertSingleChangeLabel = confirmRevertDialog
-                .shadowRoot.querySelector('.revertSingleChange');
-            const revertSubmissionLabel = confirmRevertDialog.
-                shadowRoot.querySelector('.revertSubmission');
-            assert(revertSingleChangeLabel.innerText.trim() ===
-                'Revert single change');
-            assert(revertSubmissionLabel.innerText.trim() ===
-                'Revert entire submission (2 Changes)');
-            let expectedMsg = 'Revert submission 199 0' + '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-              'Reverted Changes:' + '\n' +
-              '1234567890:random' + '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            assert.equal(confirmRevertDialog._message, expectedMsg);
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            MockInteractions.tap(radioInputs[0]);
-            flush(() => {
-              expectedMsg = 'Revert "random commit message"\n\nThis reverts '
-               + 'commit 2000.\n\nReason'
-               + ' for revert: <INSERT REASONING HERE>\n';
-              assert.equal(confirmRevertDialog._message, expectedMsg);
-              done();
-            });
-          });
-        });
-
-        test('submit fails if message is not edited', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          const fireStub = 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();
-            });
-          });
-        });
-      });
-    });
-
-    suite('mark change private', () => {
-      setup(() => {
-        const privateAction = {
-          __key: 'private',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Mark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          private: privateAction,
-        };
-
-        element.change.is_private = false;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the mark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          done();
-        });
-      });
-
-      test('private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          element.setActionOverflow('change', 'private', false);
-          flushAsynchronousOperations();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          done();
-        });
-      });
-    });
-
-    suite('unmark private change', () => {
-      setup(() => {
-        const unmarkPrivateAction = {
-          __key: 'private.delete',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Unmark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          'private.delete': unmarkPrivateAction,
-        };
-
-        element.change.is_private = true;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the unmark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          done();
-        });
-      });
-
-      test('unmark the private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          element.setActionOverflow('change', 'private.delete', false);
-          flushAsynchronousOperations();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          done();
-        });
-      });
-    });
-
-    suite('delete change', () => {
-      let fireActionStub;
-      let deleteAction;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        deleteAction = {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        };
-        element.actions = {
-          '/': deleteAction,
-        };
-      });
-
-      test('does not delete on action', () => {
-        element._handleDeleteTap();
-        assert.isFalse(fireActionStub.called);
-      });
-
-      test('shows confirm dialog', () => {
-        element._handleDeleteTap();
-        assert.isFalse(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
-      });
-
-      test('hides delete confirm on cancel', () => {
-        element._handleDeleteTap();
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button:not([primary])'));
-        flushAsynchronousOperations();
-        assert.isTrue(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        assert.isFalse(fireActionStub.called);
-      });
-    });
-
-    suite('ignore change', () => {
-      setup(done => {
-        sandbox.stub(element, '_fireAction');
-
-        const IgnoreAction = {
-          __key: 'ignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Ignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          ignore: IgnoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('make sure the ignore button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="ignore"]'));
-          });
-
-      test('ignoring change', () => {
-        assert.isOk(element.$.moreActions.shadowRoot
-            .querySelector('span[data-id="ignore-change"]'));
-        element.setActionOverflow('change', 'ignore', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="ignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="ignore-change"]'));
-      });
-    });
-
-    suite('unignore change', () => {
-      setup(done => {
-        sandbox.stub(element, '_fireAction');
-
-        const UnignoreAction = {
-          __key: 'unignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Unignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unignore: UnignoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-      });
-
-      test('unignoring change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-        element.setActionOverflow('change', 'unignore', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-      });
-    });
-
-    suite('reviewed change', () => {
-      setup(done => {
-        sandbox.stub(element, '_fireAction');
-
-        const ReviewedAction = {
-          __key: 'reviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark reviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          reviewed: ReviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('make sure the reviewed button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="reviewed"]'));
-          });
-
-      test('reviewing change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-        element.setActionOverflow('change', 'reviewed', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="reviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-      });
-    });
-
-    suite('unreviewed change', () => {
-      setup(done => {
-        sandbox.stub(element, '_fireAction');
-
-        const UnreviewedAction = {
-          __key: 'unreviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark unreviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unreviewed: UnreviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unreviewed button not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-      });
-
-      test('unreviewed change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-        element.setActionOverflow('change', 'unreviewed', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-      });
-    });
-
-    suite('quick approve', () => {
-      setup(() => {
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                '-1': '',
-                ' 0': '',
-                '+1': '',
-              },
-            },
-          },
-          permitted_labels: {
-            foo: ['-1', ' 0', '+1'],
-          },
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('added when can approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-      });
-
-      test('hide quick approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-        assert.isFalse(element._hideQuickApproveAction);
-
-        // Assert approve button gets removed from list of buttons.
-        element.hideQuickApproveAction();
-        flushAsynchronousOperations();
-        const approveButtonUpdated =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButtonUpdated);
-        assert.isTrue(element._hideQuickApproveAction);
-      });
-
-      test('is first in list of secondary actions', () => {
-        const approveButton = element.$.secondaryActions
-            .querySelector('gr-button');
-        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-      });
-
-      test('not added when already approved', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              approved: {},
-              values: {},
-            },
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('not added when label not permitted', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {values: {}},
-          },
-          permitted_labels: {
-            bar: [],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            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.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']'));
-        flushAsynchronousOperations();
-        assert.isTrue(fireActionStub.called);
-        assert.isTrue(fireActionStub.calledWith('/review'));
-        const payload = fireActionStub.lastCall.args[3];
-        assert.deepEqual(payload.labels, {foo: '+1'});
-      });
-
-      test('not added when multiple labels are required', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {values: {}},
-            bar: {values: {}},
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('button label for missing approval', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                ' 0': '',
-                '+1': '',
-              },
-            },
-            bar: {approved: {}, values: {}},
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-      });
-
-      test('no quick approve if score is not maximal for a label', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('approving label with a non-max score', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
-      });
-    });
-
-    test('adds download revision action', () => {
-      const handler = sandbox.stub();
-      element.addEventListener('download-tap', handler);
-      assert.ok(element.revisionActions.download);
-      element._handleDownloadTap();
-      flushAsynchronousOperations();
-
-      assert.isTrue(handler.called);
-    });
-
-    test('changing changeNum or patchNum does not reload', () => {
-      const reloadStub = sandbox.stub(element, 'reload');
-      element.changeNum = 123;
-      assert.isFalse(reloadStub.called);
-      element.latestPatchNum = 456;
-      assert.isFalse(reloadStub.called);
-    });
-
-    test('_toSentenceCase', () => {
-      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
-      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
-      assert.equal(element._toSentenceCase('b'), 'B');
-      assert.equal(element._toSentenceCase(''), '');
-      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
-    });
-
-    suite('setActionOverflow', () => {
-      test('move action from overflow', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-        element.setActionOverflow('revision', 'cherrypick', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.notEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-      });
-
-      test('move action to overflow', () => {
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        element.setActionOverflow('revision', 'submit', true);
-        flushAsynchronousOperations();
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[3].id, 'submit-revision');
-      });
-
-      suite('_waitForChangeReachable', () => {
-        setup(() => {
-          sandbox.stub(element, 'async', fn => fn());
-        });
-
-        const makeGetChange = numTries => () => {
-          if (numTries === 1) {
-            return Promise.resolve({_number: 123});
-          } else {
-            numTries--;
-            return Promise.resolve(undefined);
-          }
-        };
-
-        test('succeed', () => {
-          sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(5));
-          return element._waitForChangeReachable(123).then(success => {
-            assert.isTrue(success);
-          });
-        });
-
-        test('fail', () => {
-          sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(6));
-          return element._waitForChangeReachable(123).then(success => {
-            assert.isFalse(success);
-          });
-        });
-      });
-    });
-
-    suite('_send', () => {
-      let cleanup;
-      let payload;
-      let onShowError;
-      let onShowAlert;
-      let getResponseObjectStub;
-
-      setup(() => {
-        cleanup = sinon.stub();
-        element.changeNum = 42;
-        element.change._number = 42;
-        element.latestPatchNum = 12;
-        payload = {foo: 'bar'};
-
-        onShowError = sinon.stub();
-        element.addEventListener('show-error', onShowError);
-        onShowAlert = sinon.stub();
-        element.addEventListener('show-alert', onShowAlert);
-      });
-
-      suite('happy path', () => {
-        let sendStub;
-        setup(() => {
-          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', 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();
-              });
-        });
-
-        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();
-              });
-        });
-      });
-
-      suite('failure modes', () => {
-        test('non-latest', () => {
-          sandbox.stub(element, 'fetchChangeUpdates')
-              .returns(Promise.resolve({isLatest: false}));
-          const sendStub = sandbox.stub(element.$.restAPI,
-              'executeChangeAction');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isTrue(onShowAlert.calledOnce);
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isFalse(sendStub.called);
-              });
-        });
-
-        test('send fails', () => {
-          sandbox.stub(element, 'fetchChangeUpdates')
-              .returns(Promise.resolve({isLatest: true}));
-          const sendStub = sandbox.stub(element.$.restAPI,
-              'executeChangeAction',
-              (num, method, patchNum, endpoint, payload, onErr) => {
-                onErr();
-                return Promise.resolve(null);
-              });
-          const handleErrorStub = sandbox.stub(element, '_handleResponseError');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.called);
-                assert.isTrue(sendStub.calledOnce);
-                assert.isTrue(handleErrorStub.called);
-              });
-        });
-      });
-    });
-
-    test('_handleAction reports', () => {
-      sandbox.stub(element, '_fireAction');
-      const reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
-      element._handleAction('type', 'key');
-      assert.isTrue(reportStub.called);
-      assert.equal(reportStub.lastCall.args[0], 'type-key');
-    });
-  });
-
-  suite('getChangeRevisionActions returns only some actions', () => {
-    let element;
-    let sandbox;
-    let changeRevisionActions;
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getChangeRevisionActions() {
-          return Promise.resolve(changeRevisionActions);
-        },
-        send(method, url, payload) {
-          return Promise.reject(new Error('error'));
-        },
-        getProjectConfig() { return Promise.resolve({}); },
-      });
-
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
-          .returns(Promise.resolve());
-
-      element = fixture('basic');
-      // getChangeRevisionActions is not called without
-      // set the following properies
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-
-      sandbox.stub(element.$.confirmCherrypick.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      sandbox.stub(element.$.confirmMove.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      return element.reload();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
-      changeRevisionActions = {};
-      element.reload();
-      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
-      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
-    });
-
-    test('_computeRebaseOnCurrent', () => {
-      const rebaseAction = {
-        enabled: true,
-        label: 'Rebase',
-        method: 'POST',
-        title: 'Rebase onto tip of branch or parent change',
-      };
-
-      // When rebase is enabled initially, rebaseOnCurrent should be set to
-      // true.
-      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
-
-      delete rebaseAction.enabled;
-
-      // When rebase is not enabled initially, rebaseOnCurrent should be set to
-      // false.
-      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
new file mode 100644
index 0000000..ae49a57
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -0,0 +1,2074 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-actions.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {
+  createChange,
+  createChangeMessages,
+  createRevisions,
+} from '../../../test/test-data-generators.js';
+
+const basicFixture = fixtureFromElement('gr-change-actions');
+
+const CHERRY_PICK_TYPES = {
+  SINGLE_CHANGE: 1,
+  TOPIC: 2,
+};
+// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
+suite('gr-change-actions tests', () => {
+  let element;
+
+  suite('basic tests', () => {
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getChangeRevisionActions() {
+          return Promise.resolve({
+            cherrypick: {
+              method: 'POST',
+              label: 'Cherry Pick',
+              title: 'Cherry pick change to a different branch',
+              enabled: true,
+            },
+            rebase: {
+              method: 'POST',
+              label: 'Rebase',
+              title: 'Rebase onto tip of branch or parent change',
+              enabled: true,
+            },
+            submit: {
+              method: 'POST',
+              label: 'Submit',
+              title: 'Submit patch set 2 into master',
+              enabled: true,
+            },
+            revert_submission: {
+              method: 'POST',
+              label: 'Revert submission',
+              title: 'Revert this submission',
+              enabled: true,
+            },
+          });
+        },
+        send(method, url, payload) {
+          if (method !== 'POST') {
+            return Promise.reject(new Error('bad method'));
+          }
+
+          if (url === '/changes/test~42/revisions/2/submit') {
+            return Promise.resolve({
+              ok: true,
+              text() { return Promise.resolve(')]}\'\n{}'); },
+            });
+          } else if (url === '/changes/test~42/revisions/2/rebase') {
+            return Promise.resolve({
+              ok: true,
+              text() { return Promise.resolve(')]}\'\n{}'); },
+            });
+          }
+
+          return Promise.reject(new Error('bad url'));
+        },
+        getProjectConfig() { return Promise.resolve({}); },
+      });
+
+      sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
+          .returns(Promise.resolve());
+
+      element = basicFixture.instantiate();
+      element.change = {};
+      element.changeNum = '42';
+      element.latestPatchNum = '2';
+      element.actions = {
+        '/': {
+          method: 'DELETE',
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        },
+      };
+      sinon.stub(element.$.confirmCherrypick.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      sinon.stub(element.$.confirmMove.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+
+      return element.reload();
+    });
+
+    test('show-revision-actions event should fire', done => {
+      const spy = sinon.spy(element, '_sendShowRevisionActions');
+      element.reload();
+      flush(() => {
+        assert.isTrue(spy.called);
+        done();
+      });
+    });
+
+    test('primary and secondary actions split properly', () => {
+      // Submit should be the only primary action.
+      assert.equal(element._topLevelPrimaryActions.length, 1);
+      assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
+      assert.equal(element._topLevelSecondaryActions.length,
+          element._topLevelActions.length - 1);
+    });
+
+    test('revert submission action is skipped', () => {
+      assert.equal(element._allActionValues.filter(action =>
+        action.__key === 'submit').length, 1);
+      assert.equal(element._allActionValues.filter(action =>
+        action.__key === 'revert_submission').length, 0);
+    });
+
+    test('_shouldHideActions', () => {
+      assert.isTrue(element._shouldHideActions(undefined, true));
+      assert.isTrue(element._shouldHideActions({base: {}}, false));
+      assert.isFalse(element._shouldHideActions({base: ['test']}, false));
+    });
+
+    test('plugin revision actions', done => {
+      sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
+          Promise.resolve('the-url'));
+      element.revisionActions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.revisionActions['plugin~action']);
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+            element.changeNum, element.latestPatchNum, '/plugin~action'));
+        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
+        done();
+      });
+    });
+
+    test('plugin change actions', async () => {
+      sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
+          Promise.resolve('the-url'));
+      element.actions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.actions['plugin~action']);
+      await flush();
+      assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+          element.changeNum, undefined, '/plugin~action'));
+      assert.equal(element.actions['plugin~action'].__url, 'the-url');
+    });
+
+    test('not supported actions are filtered out', () => {
+      element.revisionActions = {followup: {}};
+      assert.equal(element.querySelectorAll(
+          'section gr-button[data-action-type="revision"]').length, 0);
+    });
+
+    test('getActionDetails', () => {
+      element.revisionActions = {
+        'plugin~action': {},
+        ...element.revisionActions,
+      };
+      assert.isUndefined(element.getActionDetails('rubbish'));
+      assert.strictEqual(element.revisionActions['plugin~action'],
+          element.getActionDetails('plugin~action'));
+      assert.strictEqual(element.revisionActions['rebase'],
+          element.getActionDetails('rebase'));
+    });
+
+    test('hide revision action', done => {
+      flush(() => {
+        const buttonEl = element.shadowRoot
+            .querySelector('[data-action-key="submit"]');
+        assert.isOk(buttonEl);
+        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenActions, 1);
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenActions, 1);
+        flush(() => {
+          const buttonEl = element.shadowRoot
+              .querySelector('[data-action-key="submit"]');
+          assert.isNotOk(buttonEl);
+
+          element.setActionHidden(element.ActionType.REVISION,
+              element.RevisionActions.SUBMIT, false);
+          flush(() => {
+            const buttonEl = element.shadowRoot
+                .querySelector('[data-action-key="submit"]');
+            assert.isOk(buttonEl);
+            assert.isFalse(buttonEl.hasAttribute('hidden'));
+            done();
+          });
+        });
+      });
+    });
+
+    test('buttons exist', done => {
+      element._loading = false;
+      flush(() => {
+        const buttonEls = dom(element.root)
+            .querySelectorAll('gr-button');
+        const menuItems = element.$.moreActions.items;
+
+        // Total button number is one greater than the number of total actions
+        // due to the existence of the overflow menu trigger.
+        assert.equal(buttonEls.length + menuItems.length,
+            element._allActionValues.length + 1);
+        assert.isFalse(element.hidden);
+        done();
+      });
+    });
+
+    test('delete buttons have explicit labels', done => {
+      flush(() => {
+        const deleteItems = element.$.moreActions.items
+            .filter(item => item.id.startsWith('delete'));
+        assert.equal(deleteItems.length, 1);
+        assert.notEqual(deleteItems[0].name);
+        assert.equal(deleteItems[0].name, 'Delete change');
+        done();
+      });
+    });
+
+    test('get revision object from change', () => {
+      const revObj = {_number: 2, foo: 'bar'};
+      const change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: revObj,
+        },
+      };
+      assert.deepEqual(element._getRevision(change, '2'), revObj);
+    });
+
+    test('_actionComparator sort order', () => {
+      const actions = [
+        {label: '123', __type: 'change', __key: 'review'},
+        {label: 'abc-ro', __type: 'revision'},
+        {label: 'abc', __type: 'change'},
+        {label: 'def', __type: 'change'},
+        {label: 'def-p', __type: 'change', __primary: true},
+      ];
+
+      const result = actions.slice();
+      result.reverse();
+      result.sort(element._actionComparator.bind(element));
+      assert.deepEqual(result, actions);
+    });
+
+    test('submit change', () => {
+      const showSpy = sinon.spy(element, '_showActionDialog');
+      sinon.stub(element.$.restAPI, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.latestPatchNum = '2';
+
+      const submitButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="submit"]');
+      assert.ok(submitButton);
+      MockInteractions.tap(submitButton);
+
+      flush();
+      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+    });
+
+    test('submit change, tap on icon', done => {
+      sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake( done);
+      sinon.stub(element.$.restAPI, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.latestPatchNum = '2';
+
+      const submitIcon =
+          element.shadowRoot
+              .querySelector('gr-button[data-action-key="submit"] iron-icon');
+      assert.ok(submitIcon);
+      MockInteractions.tap(submitIcon);
+    });
+
+    test('_handleSubmitConfirm', () => {
+      const fireStub = sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_canSubmitChange').returns(true);
+      element._handleSubmitConfirm();
+      assert.isTrue(fireStub.calledOnce);
+      assert.deepEqual(fireStub.lastCall.args,
+          ['/submit', element.revisionActions.submit, true]);
+    });
+
+    test('_handleSubmitConfirm when not able to submit', () => {
+      const fireStub = sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_canSubmitChange').returns(false);
+      element._handleSubmitConfirm();
+      assert.isFalse(fireStub.called);
+    });
+
+    test('submit change with plugin hook', done => {
+      sinon.stub(element, '_canSubmitChange').callsFake(
+          () => false);
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      flush(() => {
+        const submitButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="submit"]');
+        assert.ok(submitButton);
+        MockInteractions.tap(submitButton);
+        assert.equal(fireActionStub.callCount, 0);
+
+        done();
+      });
+    });
+
+    test('chain state', () => {
+      assert.equal(element._hasKnownChainState, false);
+      element.hasParent = true;
+      assert.equal(element._hasKnownChainState, true);
+      element.hasParent = false;
+    });
+
+    test('_calculateDisabled', () => {
+      let hasKnownChainState = false;
+      const action = {__key: 'rebase', enabled: true};
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), true);
+
+      action.__key = 'delete';
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
+
+      action.__key = 'rebase';
+      hasKnownChainState = true;
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
+
+      action.enabled = false;
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
+    });
+
+    test('rebase change', done => {
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
+          'fetchRecentChanges').returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      flush(() => {
+        const rebaseButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebase"]');
+        MockInteractions.tap(rebaseButton);
+        const rebaseAction = {
+          __key: 'rebase',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Rebase',
+          method: 'POST',
+          title: 'Rebase onto tip of branch or parent change',
+        };
+        assert.isTrue(fetchChangesStub.called);
+        element._handleRebaseConfirm({detail: {base: '1234'}});
+        assert.deepEqual(fireActionStub.lastCall.args,
+            ['/rebase', rebaseAction, true, {base: '1234'}]);
+        done();
+      });
+    });
+
+    test('rebase change fires reload event', done => {
+      const eventStub = sinon.stub(element, 'dispatchEvent');
+      sinon.stub(element.$.restAPI, 'getResponseObject').returns(
+          Promise.resolve({}));
+      element._handleResponse({__key: 'rebase'}, {});
+      flush(() => {
+        assert.isTrue(eventStub.called);
+        assert.equal(eventStub.lastCall.args[0].type, 'reload');
+        done();
+      });
+    });
+
+    test(`rebase dialog gets recent changes each time it's opened`, done => {
+      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
+          'fetchRecentChanges').returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      const rebaseButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="rebase"]');
+      MockInteractions.tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledOnce);
+
+      flush(() => {
+        element.$.confirmRebase.dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+        MockInteractions.tap(rebaseButton);
+        assert.isTrue(fetchChangesStub.calledTwice);
+        done();
+      });
+    });
+
+    test('two dialogs are not shown at the same time', done => {
+      element._hasKnownChainState = true;
+      flush(() => {
+        const rebaseButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebase"]');
+        assert.ok(rebaseButton);
+        MockInteractions.tap(rebaseButton);
+        flush();
+        assert.isFalse(element.$.confirmRebase.hidden);
+        sinon.stub(element.$.restAPI, 'getChanges')
+            .returns(Promise.resolve([]));
+        element._handleCherrypickTap();
+        flush(() => {
+          assert.isTrue(element.$.confirmRebase.hidden);
+          assert.isFalse(element.$.confirmCherrypick.hidden);
+          done();
+        });
+      });
+    });
+
+    test('fullscreen-overlay-opened hides content', () => {
+      sinon.spy(element, '_handleHideBackgroundContent');
+      element.$.overlay.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-opened', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleHideBackgroundContent.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      sinon.spy(element, '_handleShowBackgroundContent');
+      element.$.overlay.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-closed', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleShowBackgroundContent.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('_setLabelValuesOnRevert', () => {
+      const labels = {'Foo': 1, 'Bar-Baz': -2};
+      const changeId = 1234;
+      sinon.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
+      const saveStub = sinon.stub(element.$.restAPI, 'saveChangeReview')
+          .returns(Promise.resolve());
+      return element._setLabelValuesOnRevert(changeId).then(() => {
+        assert.isTrue(saveStub.calledOnce);
+        assert.equal(saveStub.lastCall.args[0], changeId);
+        assert.deepEqual(saveStub.lastCall.args[2], {labels});
+      });
+    });
+
+    suite('change edits', () => {
+      test('disableEdit', () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        element.set('disableEdit', true);
+        flush();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('shows confirm dialog for delete edit', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+
+        const fireActionStub = sinon.stub(element, '_fireAction');
+        element._handleDeleteEditTap();
+        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('#confirmDeleteEditDialog')
+                .shadowRoot
+                .querySelector('gr-button[primary]'));
+        flush();
+
+        assert.equal(fireActionStub.lastCall.args[0], '/edit');
+      });
+
+      test('hide publishEdit and rebaseEdit if change is not open', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {status: 'MERGED'};
+        flush();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+      });
+
+      test('edit patchset is loaded, needs rebase', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {status: 'NEW'};
+        element.editBasedOnCurrentPatchSet = false;
+        flush();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit patchset is loaded, does not need rebase', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {status: 'NEW'};
+        element.editBasedOnCurrentPatchSet = true;
+        flush();
+
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit mode is loaded, no edit patchset', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        flush();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('normal patch set', () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        flush();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit action', done => {
+        element.addEventListener('edit-tap', () => { done(); });
+        element.set('editMode', true);
+        element.change = {status: 'NEW'};
+        flush();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+        element.change = {status: 'MERGED'};
+        flush();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        element.change = {status: 'NEW'};
+        element.set('editMode', false);
+        flush();
+
+        const editButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]');
+        assert.isOk(editButton);
+        MockInteractions.tap(editButton);
+      });
+    });
+
+    suite('cherry-pick', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        sinon.stub(window, 'alert');
+      });
+
+      test('works', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: 'POST',
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element._handleCherrypickConfirm({
+          detail: {
+            branch: '',
+            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+          },
+        });
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmCherrypick.branch = 'master';
+        element._handleCherrypickConfirm({
+          detail: {
+            branch: 'master',
+            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+          },
+        });
+        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = 'OPEN';
+        element.$.confirmCherrypick.commitNum = '123';
+
+        element._handleCherrypickConfirm({
+          detail: {
+            branch: 'master',
+            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+          },
+        });
+
+        assert.equal(element.$.confirmCherrypick.shadowRoot.
+            querySelector('#messageInput').value, 'foo message');
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick', action, true, {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: false,
+          },
+        ]);
+      });
+
+      test('cherry pick even with conflicts', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: 'POST',
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element.$.confirmCherrypick.branch = 'master';
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = 'OPEN';
+        element.$.confirmCherrypick.commitNum = '123';
+
+        element._handleCherrypickConflictConfirm();
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick', action, true, {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: true,
+          },
+        ]);
+      });
+
+      test('branch name cleared when re-open cherrypick', () => {
+        const emptyBranchName = '';
+        element.$.confirmCherrypick.branch = 'master';
+
+        element._handleCherrypickTap();
+        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+      });
+
+      suite('cherry pick topics', () => {
+        const changes = [
+          {
+            change_id: '12345678901234', topic: 'T', subject: 'random',
+            project: 'A',
+          },
+          {
+            change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
+            project: 'B',
+          },
+        ];
+        setup(done => {
+          sinon.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', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        sinon.stub(window, 'alert');
+        element.actions = {
+          move: {
+            method: 'POST',
+            label: 'Move',
+            title: 'Move the change',
+            enabled: true,
+          },
+        };
+      });
+
+      test('works', () => {
+        element._handleMoveTap();
+
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmMove.branch = 'master';
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 1);
+      });
+
+      test('branch name cleared when re-open move', () => {
+        const emptyBranchName = '';
+        element.$.confirmMove.branch = 'master';
+
+        element._handleMoveTap();
+        assert.equal(element.$.confirmMove.branch, emptyBranchName);
+      });
+    });
+
+    test('custom actions', done => {
+      // Add a button with the same key as a server-based one to ensure
+      // collisions are taken care of.
+      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
+      element.addEventListener(key + '-tap', e => {
+        assert.equal(e.detail.node.getAttribute('data-action-key'), key);
+        element.removeActionButton(key);
+        flush(() => {
+          assert.notOk(element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]'));
+          done();
+        });
+      });
+      flush(() => {
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+      });
+    });
+
+    test('_setLoadingOnButtonWithKey top-level', () => {
+      const key = 'rebase';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Rebasing...');
+
+      const button = element.shadowRoot
+          .querySelector('[data-action-key="' + key + '"]');
+      assert.isTrue(button.hasAttribute('loading'));
+      assert.isTrue(button.disabled);
+
+      assert.isOk(cleanup);
+      assert.isFunction(cleanup);
+      cleanup();
+
+      assert.isFalse(button.hasAttribute('loading'));
+      assert.isFalse(button.disabled);
+      assert.isNotOk(element._actionLoadingMessage);
+    });
+
+    test('_setLoadingOnButtonWithKey overflow menu', () => {
+      const key = 'cherrypick';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
+      assert.include(element._disabledMenuActions, 'cherrypick');
+      assert.isFunction(cleanup);
+
+      cleanup();
+
+      assert.notOk(element._actionLoadingMessage);
+      assert.notInclude(element._disabledMenuActions, 'cherrypick');
+    });
+
+    suite('abandon change', () => {
+      let alertStub;
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        alertStub = sinon.stub(window, 'alert');
+        element.actions = {
+          abandon: {
+            method: 'POST',
+            label: 'Abandon',
+            title: 'Abandon the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('abandon change with message', done => {
+        const newAbandonMsg = 'Test Abandon Message';
+        element.$.confirmAbandonDialog.message = newAbandonMsg;
+        flush(() => {
+          const abandonButton =
+              element.shadowRoot
+                  .querySelector('gr-button[data-action-key="abandon"]');
+          MockInteractions.tap(abandonButton);
+
+          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+          done();
+        });
+      });
+
+      test('abandon change with no message', done => {
+        flush(() => {
+          const abandonButton =
+              element.shadowRoot
+                  .querySelector('gr-button[data-action-key="abandon"]');
+          MockInteractions.tap(abandonButton);
+
+          assert.isUndefined(element.$.confirmAbandonDialog.message);
+          done();
+        });
+      });
+
+      test('works', () => {
+        element.$.confirmAbandonDialog.message = 'original message';
+        const restoreButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key="abandon"]');
+        MockInteractions.tap(restoreButton);
+
+        element.$.confirmAbandonDialog.message = 'foo message';
+        element._handleAbandonDialogConfirm();
+        assert.notOk(alertStub.called);
+
+        const action = {
+          __key: 'abandon',
+          __type: 'change',
+          __primary: false,
+          enabled: true,
+          label: 'Abandon',
+          method: 'POST',
+          title: 'Abandon the change',
+        };
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/abandon', action, false, {
+            message: 'foo message',
+          }]);
+      });
+    });
+
+    suite('revert change', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        element.commitMessage = 'random commit message';
+        element.change.current_revision = 'abcdef';
+        element.actions = {
+          revert: {
+            method: 'POST',
+            label: 'Revert',
+            title: 'Revert the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('revert change with plugin hook', done => {
+        const newRevertMsg = 'Modified revert msg';
+        sinon.stub(element.$.confirmRevertDialog, '_modifyRevertMsg').callsFake(
+            () => newRevertMsg);
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        sinon.stub(element.$.restAPI, 'getChanges')
+            .returns(Promise.resolve([
+              {change_id: '12345678901234', topic: 'T', subject: 'random'},
+              {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
+            ]));
+        sinon.stub(element.$.confirmRevertDialog,
+            '_populateRevertSubmissionMessage').callsFake(() => 'original msg');
+        flush(() => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
+            done();
+          });
+        });
+      });
+
+      suite('revert change submitted together', () => {
+        let getChangesStub;
+        setup(() => {
+          element.change = {
+            submission_id: '199 0',
+            current_revision: '2000',
+          };
+          getChangesStub = sinon.stub(element.$.restAPI, 'getChanges')
+              .returns(Promise.resolve([
+                {change_id: '12345678901234', topic: 'T', subject: 'random'},
+                {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
+              ]));
+        });
+
+        test('confirm revert dialog shows both options', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const revertSingleChangeLabel = confirmRevertDialog
+                .shadowRoot.querySelector('.revertSingleChange');
+            const revertSubmissionLabel = confirmRevertDialog.
+                shadowRoot.querySelector('.revertSubmission');
+            assert(revertSingleChangeLabel.innerText.trim() ===
+                'Revert single change');
+            assert(revertSubmissionLabel.innerText.trim() ===
+                'Revert entire submission (2 Changes)');
+            let expectedMsg = 'Revert submission 199 0' + '\n\n' +
+              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+              'Reverted Changes:' + '\n' +
+              '1234567890:random' + '\n' +
+              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '\n';
+            assert.equal(confirmRevertDialog._message, expectedMsg);
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            MockInteractions.tap(radioInputs[0]);
+            flush(() => {
+              expectedMsg = 'Revert "random commit message"\n\nThis reverts '
+               + 'commit 2000.\n\nReason'
+               + ' for revert: <INSERT REASONING HERE>\n';
+              assert.equal(confirmRevertDialog._message, expectedMsg);
+              done();
+            });
+          });
+        });
+
+        test('submit fails if message is not edited', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('message modification is retained on switching', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            const revertSubmissionMsg = 'Revert submission 199 0' + '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+            'Reverted Changes:' + '\n' +
+            '1234567890:random' + '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+            const singleChangeMsg =
+            'Revert "random commit message"\n\nThis reverts '
+              + 'commit 2000.\n\nReason'
+              + ' for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+            const newRevertMsg = revertSubmissionMsg + 'random';
+            const newSingleChangeMsg = singleChangeMsg + 'random';
+            confirmRevertDialog._message = newRevertMsg;
+            MockInteractions.tap(radioInputs[0]);
+            flush(() => {
+              assert.equal(confirmRevertDialog._message, singleChangeMsg);
+              confirmRevertDialog._message = newSingleChangeMsg;
+              MockInteractions.tap(radioInputs[1]);
+              flush(() => {
+                assert.equal(confirmRevertDialog._message, newRevertMsg);
+                MockInteractions.tap(radioInputs[0]);
+                flush(() => {
+                  assert.equal(
+                      confirmRevertDialog._message,
+                      newSingleChangeMsg
+                  );
+                  done();
+                });
+              });
+            });
+          });
+        });
+      });
+
+      suite('revert single change', () => {
+        setup(() => {
+          element.change = {
+            submission_id: '199',
+            current_revision: '2000',
+          };
+          sinon.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 = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('confirm revert dialog shows no radio button', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            assert.equal(radioInputs.length, 0);
+            const msg = 'Revert "random commit message"\n\n'
+              + 'This reverts commit 2000.\n\nReason '
+              + 'for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, msg);
+            const editedMsg = msg + 'hello';
+            confirmRevertDialog._message += 'hello';
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+              assert.equal(fireActionStub.getCall(0).args[3].message,
+                  editedMsg);
+              done();
+            });
+          });
+        });
+      });
+    });
+
+    suite('mark change private', () => {
+      setup(() => {
+        const privateAction = {
+          __key: 'private',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Mark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          private: privateAction,
+        };
+
+        element.change.is_private = false;
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the mark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="private"]'));
+          done();
+        });
+      });
+
+      test('private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private-change"]'));
+          element.setActionOverflow('change', 'private', false);
+          flush();
+          assert.isOk(element.shadowRoot
+              .querySelector('[data-action-key="private"]'));
+          assert.isNotOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private-change"]'));
+          done();
+        });
+      });
+    });
+
+    suite('unmark private change', () => {
+      setup(() => {
+        const unmarkPrivateAction = {
+          __key: 'private.delete',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Unmark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          'private.delete': unmarkPrivateAction,
+        };
+
+        element.change.is_private = true;
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the unmark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="private.delete"]'));
+          done();
+        });
+      });
+
+      test('unmark the private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private.delete-change"]')
+          );
+          element.setActionOverflow('change', 'private.delete', false);
+          flush();
+          assert.isOk(element.shadowRoot
+              .querySelector('[data-action-key="private.delete"]'));
+          assert.isNotOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private.delete-change"]')
+          );
+          done();
+        });
+      });
+    });
+
+    suite('delete change', () => {
+      let fireActionStub;
+      let deleteAction;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        deleteAction = {
+          method: 'DELETE',
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        };
+        element.actions = {
+          '/': deleteAction,
+        };
+      });
+
+      test('does not delete on action', () => {
+        element._handleDeleteTap();
+        assert.isFalse(fireActionStub.called);
+      });
+
+      test('shows confirm dialog', () => {
+        element._handleDeleteTap();
+        assert.isFalse(element.shadowRoot
+            .querySelector('#confirmDeleteDialog').hidden);
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('#confirmDeleteDialog')
+                .shadowRoot
+                .querySelector('gr-button[primary]'));
+        flush();
+        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
+      });
+
+      test('hides delete confirm on cancel', () => {
+        element._handleDeleteTap();
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('#confirmDeleteDialog')
+                .shadowRoot
+                .querySelector('gr-button:not([primary])'));
+        flush();
+        assert.isTrue(element.shadowRoot
+            .querySelector('#confirmDeleteDialog').hidden);
+        assert.isFalse(fireActionStub.called);
+      });
+    });
+
+    suite('ignore change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const IgnoreAction = {
+          __key: 'ignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Ignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          ignore: IgnoreAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('make sure the ignore button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.shadowRoot
+                .querySelector('[data-action-key="ignore"]'));
+          });
+
+      test('ignoring change', () => {
+        assert.isOk(element.$.moreActions.shadowRoot
+            .querySelector('span[data-id="ignore-change"]'));
+        element.setActionOverflow('change', 'ignore', false);
+        flush();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="ignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="ignore-change"]'));
+      });
+    });
+
+    suite('unignore change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const UnignoreAction = {
+          __key: 'unignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Unignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unignore: UnignoreAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('unignore button is not outside of the overflow menu', () => {
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="unignore"]'));
+      });
+
+      test('unignoring change', () => {
+        assert.isOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unignore-change"]'));
+        element.setActionOverflow('change', 'unignore', false);
+        flush();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="unignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unignore-change"]'));
+      });
+    });
+
+    suite('reviewed change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const ReviewedAction = {
+          __key: 'reviewed',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Mark reviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          reviewed: ReviewedAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('action is enabled', () => {
+        assert.equal(element._allActionValues.filter(action =>
+          action.__key === 'reviewed').length, 1);
+      });
+
+      test('action is skipped when attention set is enabled', () => {
+        element._config = {
+          change: {enable_attention_set: true},
+        };
+        assert.equal(element._allActionValues.filter(action =>
+          action.__key === 'reviewed').length, 0);
+      });
+
+      test('make sure the reviewed button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.shadowRoot
+                .querySelector('[data-action-key="reviewed"]'));
+          });
+
+      test('reviewing change', () => {
+        assert.isOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="reviewed-change"]'));
+        element.setActionOverflow('change', 'reviewed', false);
+        flush();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="reviewed"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="reviewed-change"]'));
+      });
+    });
+
+    suite('unreviewed change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const UnreviewedAction = {
+          __key: 'unreviewed',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Mark unreviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unreviewed: UnreviewedAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('unreviewed button not outside of the overflow menu', () => {
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="unreviewed"]'));
+      });
+
+      test('unreviewed change', () => {
+        assert.isOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unreviewed-change"]'));
+        element.setActionOverflow('change', 'unreviewed', false);
+        flush();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="unreviewed"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unreviewed-change"]'));
+      });
+    });
+
+    suite('quick approve', () => {
+      setup(() => {
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              values: {
+                '-1': '',
+                ' 0': '',
+                '+1': '',
+              },
+            },
+          },
+          permitted_labels: {
+            foo: ['-1', ' 0', '+1'],
+          },
+        };
+        flush();
+      });
+
+      test('added when can approve', () => {
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNotNull(approveButton);
+      });
+
+      test('hide quick approve', () => {
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNotNull(approveButton);
+        assert.isFalse(element._hideQuickApproveAction);
+
+        // Assert approve button gets removed from list of buttons.
+        element.hideQuickApproveAction();
+        flush();
+        const approveButtonUpdated =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButtonUpdated);
+        assert.isTrue(element._hideQuickApproveAction);
+      });
+
+      test('is first in list of secondary actions', () => {
+        const approveButton = element.$.secondaryActions
+            .querySelector('gr-button');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('not added when already approved', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              approved: {},
+              values: {},
+            },
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('not added when label not permitted', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {values: {}},
+          },
+          permitted_labels: {
+            bar: [],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approves when tapped', () => {
+        const fireActionStub = sinon.stub(element, '_fireAction');
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']'));
+        flush();
+        assert.isTrue(fireActionStub.called);
+        assert.isTrue(fireActionStub.calledWith('/review'));
+        const payload = fireActionStub.lastCall.args[3];
+        assert.deepEqual(payload.labels, {foo: 1});
+      });
+
+      test('not added when multiple labels are required', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {values: {}},
+            bar: {values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('button label for missing approval', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              values: {
+                ' 0': '',
+                '+1': '',
+              },
+            },
+            bar: {approved: {}, values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('no quick approve if score is not maximal for a label', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1'],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approving label with a non-max score', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
+      });
+    });
+
+    test('adds download revision action', () => {
+      const handler = sinon.stub();
+      element.addEventListener('download-tap', handler);
+      assert.ok(element.revisionActions.download);
+      element._handleDownloadTap();
+      flush();
+
+      assert.isTrue(handler.called);
+    });
+
+    test('changing changeNum or patchNum does not reload', () => {
+      const reloadStub = sinon.stub(element, 'reload');
+      element.changeNum = 123;
+      assert.isFalse(reloadStub.called);
+      element.latestPatchNum = 456;
+      assert.isFalse(reloadStub.called);
+    });
+
+    test('_toSentenceCase', () => {
+      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
+      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
+      assert.equal(element._toSentenceCase('b'), 'B');
+      assert.equal(element._toSentenceCase(''), '');
+      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+    });
+
+    suite('setActionOverflow', () => {
+      test('move action from overflow', () => {
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="cherrypick"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+        element.setActionOverflow('revision', 'cherrypick', false);
+        flush();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="cherrypick"]'));
+        assert.notEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+      });
+
+      test('move action to overflow', () => {
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="submit"]'));
+        element.setActionOverflow('revision', 'submit', true);
+        flush();
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="submit"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[3].id, 'submit-revision');
+      });
+
+      suite('_waitForChangeReachable', () => {
+        setup(() => {
+          sinon.stub(element, 'async').callsFake( fn => fn());
+        });
+
+        const makeGetChange = numTries => () => {
+          if (numTries === 1) {
+            return Promise.resolve({_number: 123});
+          } else {
+            numTries--;
+            return Promise.resolve(undefined);
+          }
+        };
+
+        test('succeed', () => {
+          sinon.stub(element.$.restAPI, 'getChange')
+              .callsFake( makeGetChange(5));
+          return element._waitForChangeReachable(123).then(success => {
+            assert.isTrue(success);
+          });
+        });
+
+        test('fail', () => {
+          sinon.stub(element.$.restAPI, 'getChange')
+              .callsFake( makeGetChange(6));
+          return element._waitForChangeReachable(123).then(success => {
+            assert.isFalse(success);
+          });
+        });
+      });
+    });
+
+    suite('_send', () => {
+      let cleanup;
+      let payload;
+      let onShowError;
+      let onShowAlert;
+      let getResponseObjectStub;
+
+      setup(() => {
+        cleanup = sinon.stub();
+        element.changeNum = 42;
+        element.change._number = 42;
+        element.latestPatchNum = 12;
+        element.change = {
+          ...createChange(),
+          revisions: createRevisions(element.latestPatchNum),
+          messages: createChangeMessages(1),
+        };
+        payload = {foo: 'bar'};
+
+        onShowError = sinon.stub();
+        element.addEventListener('show-error', onShowError);
+        onShowAlert = sinon.stub();
+        element.addEventListener('show-alert', onShowAlert);
+      });
+
+      suite('happy path', () => {
+        let sendStub;
+        setup(() => {
+          sinon.stub(element.$.restAPI, 'getChangeDetail')
+              .returns(Promise.resolve({
+                ...createChange(),
+                // element has latest info
+                revisions: createRevisions(element.latestPatchNum),
+                messages: createChangeMessages(1),
+              }));
+          sendStub = sinon.stub(element.$.restAPI, 'executeChangeAction')
+              .returns(Promise.resolve({}));
+          getResponseObjectStub = sinon.stub(element.$.restAPI,
+              'getResponseObject');
+          sinon.stub(GerritNav,
+              'navigateToChange').returns(Promise.resolve(true));
+        });
+
+        test('change action', async () => {
+          await element._send('DELETE', payload, '/endpoint', false, cleanup);
+          assert.isFalse(onShowError.called);
+          assert.isTrue(cleanup.calledOnce);
+          assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+              undefined, payload));
+        });
+
+        suite('show revert submission dialog', () => {
+          setup(() => {
+            element.change.submission_id = '199';
+            element.change.current_revision = '2000';
+            sinon.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';
+            sinon.stub(element.$.confirmRevertSubmissionDialog,
+                '_modifyRevertSubmissionMsg').returns(modifiedMsg);
+            element.showRevertSubmissionDialog();
+            flush(() => {
+              const msg = element.$.confirmRevertSubmissionDialog.message;
+              assert.equal(msg, modifiedMsg);
+              done();
+            });
+          });
+        });
+
+        suite('single changes revert', () => {
+          let navigateToSearchQueryStub;
+          setup(() => {
+            getResponseObjectStub
+                .returns(Promise.resolve({revert_changes: [
+                  {change_id: 12345},
+                ]}));
+            navigateToSearchQueryStub = sinon.stub(GerritNav,
+                'navigateToSearchQuery');
+          });
+
+          test('revert submission single change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).
+                  then(() => {
+                    assert.isTrue(navigateToSearchQueryStub.called);
+                    done();
+                  });
+            });
+          });
+        });
+
+        suite('multiple changes revert', () => {
+          let showActionDialogStub;
+          let navigateToSearchQueryStub;
+          setup(() => {
+            getResponseObjectStub
+                .returns(Promise.resolve({revert_changes: [
+                  {change_id: 12345, topic: 'T'},
+                  {change_id: 23456, topic: 'T'},
+                ]}));
+            showActionDialogStub = sinon.stub(element, '_showActionDialog');
+            navigateToSearchQueryStub = sinon.stub(GerritNav,
+                'navigateToSearchQuery');
+          });
+
+          test('revert submission multiple change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).then(
+                  () => {
+                    assert.isFalse(showActionDialogStub.called);
+                    assert.isTrue(navigateToSearchQueryStub.calledWith(
+                        'topic: T'));
+                    done();
+                  });
+            });
+          });
+        });
+
+        test('revision action', done => {
+          element
+              ._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+                    12, payload));
+                done();
+              });
+        });
+      });
+
+      suite('failure modes', () => {
+        test('non-latest', () => {
+          sinon.stub(element.$.restAPI, 'getChangeDetail')
+              .returns(Promise.resolve({
+                ...createChange(),
+                // new patchset was uploaded
+                revisions: createRevisions(element.latestPatchNum + 1),
+                messages: createChangeMessages(1),
+              }));
+          const sendStub = sinon.stub(element.$.restAPI,
+              'executeChangeAction');
+
+          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isTrue(onShowAlert.calledOnce);
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isFalse(sendStub.called);
+              });
+        });
+
+        test('send fails', () => {
+          sinon.stub(element.$.restAPI, 'getChangeDetail')
+              .returns(Promise.resolve({
+                ...createChange(),
+                // element has latest info
+                revisions: createRevisions(element.latestPatchNum),
+                messages: createChangeMessages(1),
+              }));
+          const sendStub = sinon.stub(element.$.restAPI,
+              'executeChangeAction').callsFake(
+              (num, method, patchNum, endpoint, payload, onErr) => {
+                onErr();
+                return Promise.resolve(null);
+              });
+          const handleErrorStub = sinon.stub(element, '_handleResponseError');
+
+          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.called);
+                assert.isTrue(sendStub.calledOnce);
+                assert.isTrue(handleErrorStub.called);
+              });
+        });
+      });
+    });
+
+    test('_handleAction reports', () => {
+      sinon.stub(element, '_fireAction');
+      element.actions = {
+        key: {
+          __key: 'key',
+          __type: 'type',
+        },
+      };
+
+      const reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      element._handleAction('type', 'key');
+      assert.isTrue(reportStub.called);
+      assert.equal(reportStub.lastCall.args[0], 'type-key');
+    });
+  });
+
+  suite('getChangeRevisionActions returns only some actions', () => {
+    let element;
+
+    let changeRevisionActions;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getChangeRevisionActions() {
+          return Promise.resolve(changeRevisionActions);
+        },
+        send(method, url, payload) {
+          return Promise.reject(new Error('error'));
+        },
+        getProjectConfig() { return Promise.resolve({}); },
+      });
+
+      sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
+          .returns(Promise.resolve());
+
+      element = basicFixture.instantiate();
+      // getChangeRevisionActions is not called without
+      // set the following properies
+      element.change = {};
+      element.changeNum = '42';
+      element.latestPatchNum = '2';
+
+      sinon.stub(element.$.confirmCherrypick.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      sinon.stub(element.$.confirmMove.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      return element.reload();
+    });
+
+    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
+      changeRevisionActions = {};
+      element.reload();
+      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
+      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
+    });
+
+    test('_computeRebaseOnCurrent', () => {
+      const rebaseAction = {
+        enabled: true,
+        label: 'Rebase',
+        method: 'POST',
+        title: 'Rebase onto tip of branch or parent change',
+      };
+
+      // When rebase is enabled initially, rebaseOnCurrent should be set to
+      // true.
+      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
+
+      delete rebaseAction.enabled;
+
+      // When rebase is not enabled initially, rebaseOnCurrent should be set to
+      // false.
+      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
deleted file mode 100644
index d08f529..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
+++ /dev/null
@@ -1,183 +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">
-<meta charset="utf-8">
-<title>gr-change-metadata</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="element">
-  <template>
-    <gr-change-metadata mutable="true"></gr-change-metadata>
-  </template>
-</test-fixture>
-
-<test-fixture id="plugin-host">
-  <template>
-    <gr-plugin-host></gr-plugin-host>
-  </template>
-</test-fixture>
-
-<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 pluginApi = _testOnly_initGerritPluginApi();
-
-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(
-        dom(element.root).querySelector(selector))[name];
-  };
-
-  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();
-    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-it_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
new file mode 100644
index 0000000..e812657
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
@@ -0,0 +1,185 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import './gr-change-metadata.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const testHtmlPlugin = document.createElement('dom-module');
+testHtmlPlugin.innerHTML = `
+    <template>
+      <style>
+        html {
+          --change-metadata-assignee: {
+            display: none;
+          }
+          --change-metadata-label-status: {
+            display: none;
+          }
+          --change-metadata-strategy: {
+            display: none;
+          }
+          --change-metadata-topic: {
+            display: none;
+          }
+        }
+      </style>
+    </template>
+  `;
+testHtmlPlugin.register('my-plugin-style');
+
+const basicFixture = fixtureFromTemplate(
+    html`<gr-change-metadata mutable="true"></gr-change-metadata>`
+);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-metadata integration tests', () => {
+  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(
+        element.root.querySelector(selector))[name];
+  };
+
+  function createElement() {
+    const element = basicFixture.instantiate();
+    element.change = {labels, status: 'NEW'};
+    element.revision = {};
+    return element;
+  }
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+      deleteVote() { return Promise.resolve({ok: true}); },
+    });
+  });
+
+  teardown(() => {
+    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();
+      pluginApi.install(plugin => {
+        plugin.registerStyleModule('change-metadata', 'my-plugin-style');
+      }, undefined, 'http://test.com/plugins/style.js');
+      element = createElement();
+      getPluginLoader().loadPlugins([]);
+      getPluginLoader().awaitPluginsLoaded()
+          .then(() => {
+            flush(done);
+          });
+    });
+
+    teardown(() => {
+      document.body.querySelectorAll('custom-style')
+          .forEach(style => style.remove());
+    });
+
+    for (const sectionSelector of sectionSelectors) {
+      test('section.strategy may have display: none', () => {
+        assert.equal(getStyle(sectionSelector, 'display'), 'none');
+      });
+    }
+  });
+
+  suite('label updates', () => {
+    let plugin;
+
+    setup(() => {
+      pluginApi.install(p => {
+        plugin = p;
+        plugin.registerStyleModule('change-metadata', 'my-plugin-style');
+      }, undefined, 'http://test.com/plugins/style.js');
+      sinon.stub(getPluginLoader(), 'arePluginsLoaded').returns(true);
+      getPluginLoader().loadPlugins([]);
+      element = createElement();
+    });
+
+    teardown(() => {
+      document.body.querySelectorAll('custom-style')
+          .forEach(style => style.remove());
+    });
+
+    test('labels changed callback', done => {
+      let callCount = 0;
+      const labelChangeSpy = sinon.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);
+    });
+  });
+});
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
deleted file mode 100644
index 7d4e878..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ /dev/null
@@ -1,542 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 HASHTAG_ADD_MESSAGE = 'Add Hashtag';
-
-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 = {
-  /**
-   * This certificate status is bad.
-   */
-  BAD: 'BAD',
-  /**
-   * This certificate status is OK.
-   */
-  OK: 'OK',
-  /**
-   * This certificate status is TRUSTED.
-   */
-  TRUSTED: 'TRUSTED',
-};
-
-/**
- * @extends Polymer.Element
- */
-class GrChangeMetadata extends mixinBehaviors( [
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-metadata'; }
-  /**
-   * Fired when the change topic is changed.
-   *
-   * @event topic-changed
-   */
-
-  static get properties() {
-    return {
-    /** @type {?} */
-      change: Object,
-      labels: {
-        type: Object,
-        notify: true,
-      },
-      account: Object,
-      /** @type {?} */
-      revision: Object,
-      commitInfo: Object,
-      _mutable: {
-        type: Boolean,
-        computed: '_computeIsMutable(account)',
-      },
-      /** @type {?} */
-      serverConfig: Object,
-      parentIsCurrent: Boolean,
-      _notCurrentMessage: {
-        type: String,
-        value: NOT_CURRENT_MESSAGE,
-        readOnly: true,
-      },
-      _topicReadOnly: {
-        type: Boolean,
-        computed: '_computeTopicReadOnly(_mutable, change)',
-      },
-      _hashtagReadOnly: {
-        type: Boolean,
-        computed: '_computeHashtagReadOnly(_mutable, change)',
-      },
-      /**
-       * @type {Gerrit.PushCertificateValidation}
-       */
-      _pushCertificateValidation: {
-        type: Object,
-        computed: '_computePushCertificateValidation(serverConfig, change)',
-      },
-      _showRequirements: {
-        type: Boolean,
-        computed: '_computeShowRequirements(change)',
-      },
-
-      _assignee: Array,
-      _isWip: {
-        type: Boolean,
-        computed: '_computeIsWip(change)',
-      },
-      _newHashtag: String,
-
-      _settingTopic: {
-        type: Boolean,
-        value: false,
-      },
-
-      _currentParents: {
-        type: Array,
-        computed: '_computeParents(change, revision)',
-      },
-
-      /** @type {?} */
-      _CHANGE_ROLE: {
-        type: Object,
-        readOnly: true,
-        value: {
-          OWNER: 'owner',
-          UPLOADER: 'uploader',
-          AUTHOR: 'author',
-          COMMITTER: 'committer',
-        },
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_changeChanged(change)',
-      '_labelsChanged(change.labels)',
-      '_assigneeChanged(_assignee.*)',
-    ];
-  }
-
-  _labelsChanged(labels) {
-    this.labels = Object.assign({}, labels) || null;
-  }
-
-  _changeChanged(change) {
-    this._assignee = change.assignee ? [change.assignee] : [];
-  }
-
-  _assigneeChanged(assigneeRecord) {
-    if (!this.change || !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);
-  }
-
-  /**
-   * @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;
-  }
-
-  _isAssigneeEnabled(serverConfig) {
-    return serverConfig && serverConfig.change
-        && !!serverConfig.change.enable_assignee;
-  }
-
-  _computeStrategy(change) {
-    return SubmitTypeLabel[change.submit_type];
-  }
-
-  _computeLabelNames(labels) {
-    return Object.keys(labels).sort();
-  }
-
-  _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}));
-          }
-        });
-  }
-
-  _showAddTopic(changeRecord, settingTopic) {
-    const hasTopic = !!changeRecord &&
-        !!changeRecord.base && !!changeRecord.base.topic;
-    return !hasTopic && !settingTopic;
-  }
-
-  _showTopicChip(changeRecord, settingTopic) {
-    const hasTopic = !!changeRecord &&
-        !!changeRecord.base && !!changeRecord.base.topic;
-    return hasTopic && !settingTopic;
-  }
-
-  _showCherryPickOf(changeRecord) {
-    const hasCherryPickOf = !!changeRecord &&
-        !!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
-        !!changeRecord.base.cherry_pick_of_patch_set;
-    return hasCherryPickOf;
-  }
-
-  _handleHashtagChanged(e) {
-    const lastHashtag = this.change.hashtag;
-    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}));
-      }
-    });
-  }
-
-  _computeTopicReadOnly(mutable, change) {
-    return !mutable ||
-        !change ||
-        !change.actions ||
-        !change.actions.topic ||
-        !change.actions.topic.enabled;
-  }
-
-  _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');
-  }
-
-  _computeShowRepoBranchTogether(repo, branch) {
-    return !!repo && !!branch && repo.length + branch.length < 40;
-  }
-
-  _computeProjectUrl(project) {
-    return GerritNav.getUrlForProjectChanges(project);
-  }
-
-  _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());
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
new file mode 100644
index 0000000..f8a5940
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -0,0 +1,686 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../../styles/gr-change-metadata-shared-styles';
+import '../../../styles/gr-change-view-integration-shared-styles';
+import '../../../styles/gr-voting-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../plugins/gr-external-style/gr-external-style';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-linked-chip/gr-linked-chip';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-requirements/gr-change-requirements';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-reviewer-list/gr-reviewer-list';
+import '../../shared/gr-account-list/gr-account-list';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-metadata_html';
+import {
+  GrReviewerSuggestionsProvider,
+  SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  ChangeStatus,
+  GpgKeyInfoStatus,
+  SubmitType,
+} from '../../../constants/constants';
+import {changeIsOpen} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  AccountDetailInfo,
+  AccountInfo,
+  BranchName,
+  CommitId,
+  CommitInfo,
+  ElementPropertyDeepChange,
+  GpgKeyInfo,
+  Hashtag,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  ParentCommitInfo,
+  PatchSetNum,
+  RepoName,
+  RevisionInfo,
+  ServerInfo,
+  TopicName,
+} from '../../../types/common';
+import {assertNever} from '../../../utils/common-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
+import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
+
+const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
+
+enum ChangeRole {
+  OWNER = 'owner',
+  UPLOADER = 'uploader',
+  AUTHOR = 'author',
+  COMMITTER = 'committer',
+}
+
+export interface CommitInfoWithRequiredCommit extends CommitInfo {
+  // gr-change-view always assigns commit to CommitInfo
+  commit: CommitId;
+}
+
+const SubmitTypeLabel = new Map<SubmitType, string>([
+  [SubmitType.FAST_FORWARD_ONLY, 'Fast Forward Only'],
+  [SubmitType.MERGE_IF_NECESSARY, 'Merge if Necessary'],
+  [SubmitType.REBASE_IF_NECESSARY, 'Rebase if Necessary'],
+  [SubmitType.MERGE_ALWAYS, 'Always Merge'],
+  [SubmitType.REBASE_ALWAYS, 'Rebase Always'],
+  [SubmitType.CHERRY_PICK, 'Cherry Pick'],
+]);
+
+const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+
+interface PushCertifacteValidationInfo {
+  class: string;
+  icon: string;
+  message: string;
+}
+
+export interface GrChangeMetadata {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-change-metadata')
+export class GrChangeMetadata extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the change topic is changed.
+   *
+   * @event topic-changed
+   */
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Object, notify: true})
+  labels?: LabelNameToInfoMap;
+
+  @property({type: Object})
+  account?: AccountDetailInfo;
+
+  @property({type: Object})
+  revision?: RevisionInfo | EditRevisionInfo;
+
+  @property({type: Object})
+  commitInfo?: CommitInfoWithRequiredCommit;
+
+  @property({type: Boolean, computed: '_computeIsMutable(account)'})
+  _mutable = false;
+
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: Boolean})
+  parentIsCurrent?: boolean;
+
+  @property({type: String})
+  readonly _notCurrentMessage = NOT_CURRENT_MESSAGE;
+
+  @property({
+    type: Boolean,
+    computed: '_computeTopicReadOnly(_mutable, change)',
+  })
+  _topicReadOnly = true;
+
+  @property({
+    type: Boolean,
+    computed: '_computeHashtagReadOnly(_mutable, change)',
+  })
+  _hashtagReadOnly = true;
+
+  @property({
+    type: Object,
+    computed: '_computePushCertificateValidation(serverConfig, change)',
+  })
+  _pushCertificateValidation: PushCertifacteValidationInfo | null = null;
+
+  @property({type: Boolean, computed: '_computeShowRequirements(change)'})
+  _showRequirements = false;
+
+  @property({type: Array})
+  _assignee?: AccountInfo[];
+
+  @property({type: Boolean, computed: '_computeIsWip(change)'})
+  _isWip = false;
+
+  @property({type: String})
+  _newHashtag?: Hashtag;
+
+  @property({type: Boolean})
+  _settingTopic = false;
+
+  @property({type: Array, computed: '_computeParents(change, revision)'})
+  _currentParents: ParentCommitInfo[] = [];
+
+  @property({type: Object})
+  _CHANGE_ROLE = ChangeRole;
+
+  @observe('change.labels')
+  _labelsChanged(labels?: LabelNameToInfoMap) {
+    this.labels = {...labels} || null;
+  }
+
+  @observe('change')
+  _changeChanged(change?: ParsedChangeInfo) {
+    this._assignee = change?.assignee ? [change.assignee] : [];
+    this._settingTopic = false;
+  }
+
+  @observe('_assignee.*')
+  _assigneeChanged(
+    assigneeRecord: ElementPropertyDeepChange<GrChangeMetadata, '_assignee'>
+  ) {
+    if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
+      return;
+    }
+    const assignee = assigneeRecord.base;
+    if (assignee?.length) {
+      const acct = assignee[0];
+      if (
+        !acct._account_id ||
+        (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?: ParsedChangeInfo) {
+    return !changeIsOpen(change);
+  }
+
+  /**
+   * @return If array is empty, returns null instead so
+   * an existential check can be used to hide or show the webLinks
+   * section.
+   */
+  _computeWebLinks(
+    commitInfo?: CommitInfoWithRequiredCommit,
+    serverConfig?: ServerInfo
+  ) {
+    if (!commitInfo) {
+      return null;
+    }
+    const weblinks = GerritNav.getChangeWeblinks(
+      this.change ? this.change.project : ('' as RepoName),
+      commitInfo.commit,
+      {
+        weblinks: commitInfo.web_links,
+        config: serverConfig,
+      }
+    );
+    return weblinks.length ? weblinks : null;
+  }
+
+  _isAssigneeEnabled(serverConfig?: ServerInfo) {
+    return (
+      serverConfig &&
+      serverConfig.change &&
+      !!serverConfig.change.enable_assignee
+    );
+  }
+
+  _computeStrategy(change?: ParsedChangeInfo) {
+    if (!change?.submit_type) {
+      return '';
+    }
+
+    return SubmitTypeLabel.get(change.submit_type);
+  }
+
+  _computeLabelNames(labels?: LabelNameToInfoMap) {
+    return labels ? Object.keys(labels).sort() : [];
+  }
+
+  _handleTopicChanged(e: CustomEvent<string>) {
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    const lastTopic = this.change.topic;
+    const topic = e.detail.length ? e.detail : null;
+    this._settingTopic = true;
+    const topicChangedForChangeNumber = this.change._number;
+    this.$.restAPI
+      .setChangeTopic(topicChangedForChangeNumber, topic)
+      .then(newTopic => {
+        if (
+          !this.change ||
+          this.change._number !== topicChangedForChangeNumber
+        ) {
+          return;
+        }
+        this._settingTopic = false;
+        this.set(['change', 'topic'], newTopic);
+        if (newTopic !== lastTopic) {
+          this.dispatchEvent(
+            new CustomEvent('topic-changed', {bubbles: true, composed: true})
+          );
+        }
+      });
+  }
+
+  _showAddTopic(
+    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    settingTopic?: boolean
+  ) {
+    const hasTopic =
+      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    return !hasTopic && !settingTopic;
+  }
+
+  _showTopicChip(
+    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    settingTopic?: boolean
+  ) {
+    const hasTopic =
+      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    return hasTopic && !settingTopic;
+  }
+
+  _showCherryPickOf(
+    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
+  ) {
+    const hasCherryPickOf =
+      !!changeRecord &&
+      !!changeRecord.base &&
+      !!changeRecord.base.cherry_pick_of_change &&
+      !!changeRecord.base.cherry_pick_of_patch_set;
+    return hasCherryPickOf;
+  }
+
+  _handleHashtagChanged() {
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    if (!this._newHashtag?.length) {
+      return;
+    }
+    const newHashtag = this._newHashtag;
+    this._newHashtag = '' as Hashtag;
+    this.$.restAPI
+      .setChangeHashtag(this.change._number, {add: [newHashtag]})
+      .then(newHashtag => {
+        this.set(['change', 'hashtags'], newHashtag);
+        this.dispatchEvent(
+          new CustomEvent('hashtag-changed', {
+            bubbles: true,
+            composed: true,
+          })
+        );
+      });
+  }
+
+  _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+    return (
+      !mutable ||
+      !change ||
+      !change.actions ||
+      !change.actions.topic ||
+      !change.actions.topic.enabled
+    );
+  }
+
+  _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+    return (
+      !mutable ||
+      !change ||
+      !change.actions ||
+      !change.actions.hashtags ||
+      !change.actions.hashtags.enabled
+    );
+  }
+
+  _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+    return (
+      !mutable ||
+      !change ||
+      !change.actions ||
+      !change.actions.assignee ||
+      !change.actions.assignee.enabled
+    );
+  }
+
+  _computeTopicPlaceholder(_topicReadOnly?: boolean) {
+    // Action items in Material Design are uppercase -- placeholder label text
+    // is sentence case.
+    return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
+  }
+
+  _computeHashtagPlaceholder(_hashtagReadOnly?: boolean) {
+    return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
+  }
+
+  _computeShowRequirements(change?: ParsedChangeInfo) {
+    if (!change) {
+      return false;
+    }
+    if (change.status !== 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 object representing data for the push validation.
+   */
+  _computePushCertificateValidation(
+    serverConfig?: ServerInfo,
+    change?: ParsedChangeInfo
+  ): PushCertifacteValidationInfo | null {
+    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 GpgKeyInfoStatus.BAD:
+        return {
+          class: 'invalid',
+          icon: 'gr-icons:close',
+          message: this._problems('Push certificate is invalid', key),
+        };
+      case GpgKeyInfoStatus.OK:
+        return {
+          class: 'notTrusted',
+          icon: 'gr-icons:info',
+          message: this._problems(
+            'Push certificate is valid, but key is not trusted',
+            key
+          ),
+        };
+      case GpgKeyInfoStatus.TRUSTED:
+        return {
+          class: 'trusted',
+          icon: 'gr-icons:check',
+          message: this._problems(
+            'Push certificate is valid and key is trusted',
+            key
+          ),
+        };
+      case undefined:
+        // TODO(TS): Process it correctly
+        throw new Error('deleted certificate');
+      default:
+        assertNever(key.status, `unknown certificate status: ${key.status}`);
+    }
+  }
+
+  _problems(msg: string, key: GpgKeyInfo) {
+    if (!key || !key.problems || key.problems.length === 0) {
+      return msg;
+    }
+
+    return [msg + ':'].concat(key.problems).join('\n');
+  }
+
+  _computeShowRepoBranchTogether(repo?: RepoName, branch?: BranchName) {
+    return !!repo && !!branch && repo.length + branch.length < 40;
+  }
+
+  _computeProjectUrl(project?: RepoName) {
+    if (!project) return '';
+    return GerritNav.getUrlForProjectChanges(project);
+  }
+
+  _computeBranchUrl(project?: RepoName, branch?: BranchName) {
+    if (!project || !branch || !this.change || !this.change.status) return '';
+    return GerritNav.getUrlForBranch(
+      branch,
+      project,
+      this.change.status === ChangeStatus.NEW
+        ? 'open'
+        : this.change.status.toLowerCase()
+    );
+  }
+
+  _computeCherryPickOfUrl(
+    change?: NumericChangeId,
+    patchset?: PatchSetNum,
+    project?: RepoName
+  ) {
+    if (!change || !project) {
+      return '';
+    }
+    return GerritNav.getUrlForChangeById(change, project, patchset);
+  }
+
+  _computeTopicUrl(topic: TopicName) {
+    return GerritNav.getUrlForTopic(topic);
+  }
+
+  _computeHashtagUrl(hashtag: Hashtag) {
+    return GerritNav.getUrlForHashtag(hashtag);
+  }
+
+  _handleTopicRemoved(e: CustomEvent) {
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+    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(() => {
+        target.disabled = false;
+        return;
+      });
+  }
+
+  _handleHashtagRemoved(e: CustomEvent) {
+    e.preventDefault();
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+    target.disabled = true;
+    this.$.restAPI
+      .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
+      .then(newHashtags => {
+        target.disabled = false;
+        this.set(['change', 'hashtags'], newHashtags);
+      })
+      .catch(() => {
+        target.disabled = false;
+        return;
+      });
+  }
+
+  _computeIsWip(change?: ParsedChangeInfo) {
+    return change && !!change.work_in_progress;
+  }
+
+  _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
+    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.
+   */
+  _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
+    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 === ChangeRole.UPLOADER &&
+      rev.uploader &&
+      change.owner._account_id !== rev.uploader._account_id
+    ) {
+      return rev.uploader;
+    }
+
+    if (
+      role === ChangeRole.AUTHOR &&
+      rev.commit?.author &&
+      change.owner.email !== rev.commit.author.email
+    ) {
+      return rev.commit.author;
+    }
+
+    if (
+      role === ChangeRole.COMMITTER &&
+      rev.commit?.committer &&
+      change.owner.email !== rev.commit.committer.email &&
+      !(
+        rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
+      )
+    ) {
+      return rev.commit.committer;
+    }
+
+    return null;
+  }
+
+  _computeParents(
+    change?: ParsedChangeInfo,
+    revision?: RevisionInfo | EditRevisionInfo
+  ): ParentCommitInfo[] {
+    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?: ParentCommitInfo[]) {
+    return parents && parents.length > 1 ? 'Parents' : 'Parent';
+  }
+
+  _computeParentListClass(
+    parents?: ParentCommitInfo[],
+    parentIsCurrent?: boolean
+  ) {
+    // 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?: AccountDetailInfo) {
+    return account && !!Object.keys(account).length;
+  }
+
+  editTopic() {
+    if (this._topicReadOnly || !this.change || this.change.topic) {
+      return;
+    }
+    // Cannot use `this.$.ID` syntax because the element exists inside of a
+    // dom-if.
+    (this.shadowRoot!.querySelector(
+      '.topicEditableLabel'
+    ) as GrEditableLabel).open();
+  }
+
+  _getReviewerSuggestionsProvider(change?: ParsedChangeInfo) {
+    if (!change) {
+      return undefined;
+    }
+    const provider = GrReviewerSuggestionsProvider.create(
+      this.$.restAPI,
+      change._number,
+      SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
+    );
+    provider.init();
+    return provider;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-metadata': 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
deleted file mode 100644
index 1b18412..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
+++ /dev/null
@@ -1,365 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
new file mode 100644
index 0000000..f1d1127
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -0,0 +1,375 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    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(--negative-red-text-color);
+    }
+    .icon.trusted {
+      color: var(--positive-green-text-color);
+    }
+    .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 {
+      --account-max-length: 120px;
+      max-width: 285px;
+    }
+  </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-chip
+          account="[[change.owner]]"
+          change="[[change]]"
+          highlight-attention
+        ></gr-account-chip>
+        <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-chip
+          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
+          change="[[change]]"
+          highlight-attention
+        ></gr-account-chip>
+      </span>
+    </section>
+    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
+      <span class="title">Author</span>
+      <span class="value">
+        <gr-account-chip
+          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
+          change="[[change]]"
+        ></gr-account-chip>
+      </span>
+    </section>
+    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
+      <span class="title">Committer</span>
+      <span class="value">
+        <gr-account-chip
+          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
+          change="[[change]]"
+        ></gr-account-chip>
+      </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"
+            limit="40"
+          >
+          </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">
+      <h3 class="assistive-tech-only">Label Scores</h3>
+      <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
deleted file mode 100644
index 8e780d7..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ /dev/null
@@ -1,800 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-change-metadata</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-change-metadata></gr-change-metadata>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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',
-      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-metadata/gr-change-metadata_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
new file mode 100644
index 0000000..5bdf105
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
@@ -0,0 +1,782 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-change-metadata.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-change-metadata');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-metadata tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+
+    element = basicFixture.instantiate();
+  });
+
+  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: {}};
+    flush();
+    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: {}};
+    flush();
+    assert.isTrue(element.shadowRoot
+        .querySelector('.strategy').hasAttribute('hidden'));
+  });
+
+  test('weblinks use GerritNav interface', () => {
+    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
+        .returns([{name: 'stubb', url: '#s'}]);
+    element.commitInfo = {};
+    element.serverConfig = {};
+    flush();
+    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 = {};
+    flush();
+    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 = {};
+    flush();
+    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,
+      },
+    };
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks are visible when other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        router._generateWeblinks.bind(router));
+
+    element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
+    flush();
+    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');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        router._generateWeblinks.bind(router));
+
+    element.commitInfo = {
+      web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
+    flush();
+    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.isNotOk(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.UPLOADER));
+      });
+
+      test('_getNonOwnerRole null for uploader with no current rev', () => {
+        delete change.current_revision;
+        assert.isNotOk(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', () => {
+        change.revisions.rev1.uploader.email = 'ghh@def';
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
+            {email: 'ghi@def'});
+      });
+
+      test('_getNonOwnerRole is null if committer is same as uploader', () => {
+        assert.isNotOk(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
+      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.isNotOk(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
+      test('_getNonOwnerRole null for committer with no current rev', () => {
+        delete change.current_revision;
+        assert.isNotOk(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
+      test('_getNonOwnerRole null for committer with no commit', () => {
+        delete change.revisions.rev1.commit;
+        assert.isNotOk(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
+      test('_getNonOwnerRole null for committer with no committer', () => {
+        delete change.revisions.rev1.commit.committer;
+        assert.isNotOk(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.isNotOk(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
+      });
+
+      test('_getNonOwnerRole null for author with no current rev', () => {
+        delete change.current_revision;
+        assert.isNotOk(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
+      });
+
+      test('_getNonOwnerRole null for author with no commit', () => {
+        delete change.revisions.rev1.commit;
+        assert.isNotOk(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
+      });
+
+      test('_getNonOwnerRole null for author with no author', () => {
+        delete change.revisions.rev1.commit.author;
+        assert.isNotOk(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')},
+      owner: {},
+    };
+    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;
+      flush();
+      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;
+      flush();
+      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', () => {
+      flush();
+      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', () => {
+      flush();
+      element.account = {};
+      element.change = change;
+      flush();
+      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', () => {
+      flush();
+      element.account = {test: true};
+      change.actions.hashtags.enabled = true;
+      element.change = change;
+      flush();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+  });
+
+  suite('remove reviewer votes', () => {
+    setup(() => {
+      sinon.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: [],
+      };
+      flush();
+    });
+
+    suite('assignee field', () => {
+      const dummyAccount = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      const change = {
+        actions: {
+          assignee: {enabled: false},
+        },
+        assignee: dummyAccount,
+      };
+      let deleteStub;
+      let setStub;
+
+      setup(() => {
+        deleteStub = sinon.stub(element.$.restAPI, 'deleteAssignee');
+        setStub = sinon.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';
+      sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
+          Promise.resolve(newTopic));
+      element._handleTopicChanged({detail: newTopic});
+      const topicChangedSpy = sinon.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', () => {
+      sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
+          Promise.resolve());
+      const chip = element.shadowRoot
+          .querySelector('gr-linked-chip');
+      const remove = chip.$.remove;
+      const topicChangedSpy = sinon.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', () => {
+      flush();
+      element._newHashtag = 'new hashtag';
+      const newHashtag = ['new hashtag'];
+      sinon.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}}};
+    flush();
+
+    const label = element.shadowRoot
+        .querySelector('.topicEditableLabel');
+    assert.ok(label);
+    sinon.stub(label, 'open');
+    element.editTopic();
+    flush();
+
+    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');
+      getPluginLoader().loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element.change);
+        assert.strictEqual(hookEl.revision, element.revision);
+        done();
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
deleted file mode 100644
index b3aa98f..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
+++ /dev/null
@@ -1,28 +0,0 @@
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerStyleModule('change-metadata', 'my-plugin-style');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="my-plugin-style">
-  <template>
-    <style>
-      html {
-        --change-metadata-assignee: {
-          display: none;
-        }
-        --change-metadata-label-status: {
-          display: none;
-        }
-        --change-metadata-strategy: {
-          display: none;
-        }
-        --change-metadata-topic: {
-          display: none;
-        }
-      }
-    </style>
-  </template>
-</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
deleted file mode 100644
index d301813..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ /dev/null
@@ -1,172 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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,
-      _requirements: {
-        type: Array,
-        computed: '_computeRequirements(change)',
-      },
-      _requiredLabels: {
-        type: Array,
-        value: () => [],
-      },
-      _optionalLabels: {
-        type: Array,
-        value: () => [],
-      },
-      _showWip: {
-        type: Boolean,
-        computed: '_computeShowWip(change)',
-      },
-      _showOptionalLabels: {
-        type: Boolean,
-        value: true,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_computeLabels(change.labels.*)',
-    ];
-  }
-
-  _computeShowWip(change) {
-    return change.work_in_progress;
-  }
-
-  _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.work_in_progress) {
-      _requirements.push({
-        fallback_text: 'Work-in-progress',
-        tooltip: 'Change must not be in \'Work in Progress\' state.',
-      });
-    }
-
-    return _requirements;
-  }
-
-  _computeRequirementClass(requirementStatus) {
-    return requirementStatus ? 'approved' : '';
-  }
-
-  _computeRequirementIcon(requirementStatus) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
-  }
-
-  _computeLabels(labelsRecord) {
-    const labels = labelsRecord.base;
-    this._optionalLabels = [];
-    this._requiredLabels = [];
-
-    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';
-
-      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
-   */
-  _computeLabelClass(labelInfo) {
-    if (labelInfo.approved) { return 'approved'; }
-    if (labelInfo.rejected) { return 'rejected'; }
-    return '';
-  }
-
-  _computeShowOptional(optionalFieldsRecord) {
-    return optionalFieldsRecord.base.length ? '' : 'hidden';
-  }
-
-  _computeLabelValue(value) {
-    return (value > 0 ? '+' : '') + value;
-  }
-
-  _computeShowHideIcon(showOptionalLabels) {
-    return showOptionalLabels ?
-      'gr-icons:expand-less' :
-      'gr-icons:expand-more';
-  }
-
-  _computeSectionClass(show) {
-    return show ? '' : 'hidden';
-  }
-
-  _handleShowHide(e) {
-    this._showOptionalLabels = !this._showOptionalLabels;
-  }
-}
-
-customElements.define(GrChangeRequirements.is, GrChangeRequirements);
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
new file mode 100644
index 0000000..cdac00a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -0,0 +1,202 @@
+/**
+ * @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.
+ */
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-label/gr-label';
+import '../../shared/gr-label-info/gr-label-info';
+import '../../shared/gr-limited-text/gr-limited-text';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-requirements_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  AccountInfo,
+  QuickLabelInfo,
+  Requirement,
+  RequirementType,
+  LabelNameToInfoMap,
+  LabelInfo,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+
+interface ChangeRequirement extends Requirement {
+  satisfied: boolean;
+  style: string;
+}
+
+interface ChangeWIP {
+  type: RequirementType;
+  fallback_text: string;
+  tooltip: string;
+}
+
+interface Label {
+  labelInfo: LabelInfo;
+  icon: string;
+  style: string;
+}
+
+@customElement('gr-change-requirements')
+class GrChangeRequirements extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Boolean})
+  mutable?: boolean;
+
+  @property({type: Array, computed: '_computeRequirements(change)'})
+  _requirements?: Array<ChangeRequirement | ChangeWIP>;
+
+  @property({type: Array})
+  _requiredLabels: Label[] = [];
+
+  @property({type: Array})
+  _optionalLabels: Label[] = [];
+
+  @property({type: Boolean, computed: '_computeShowWip(change)'})
+  _showWip?: boolean;
+
+  @property({type: Boolean})
+  _showOptionalLabels = true;
+
+  _computeShowWip(change: ChangeInfo) {
+    return change.work_in_progress;
+  }
+
+  _computeRequirements(change: ChangeInfo) {
+    const _requirements: Array<ChangeRequirement | ChangeWIP> = [];
+
+    if (change.requirements) {
+      for (const requirement of change.requirements) {
+        const satisfied = requirement.status === 'OK';
+        const style = this._computeRequirementClass(satisfied);
+        _requirements.push({...requirement, satisfied, style});
+      }
+    }
+    if (change.work_in_progress) {
+      _requirements.push({
+        type: 'wip' as RequirementType,
+        fallback_text: 'Work-in-progress',
+        tooltip: "Change must not be in 'Work in Progress' state.",
+      });
+    }
+
+    return _requirements;
+  }
+
+  _computeRequirementClass(requirementStatus: boolean) {
+    return requirementStatus ? 'approved' : '';
+  }
+
+  _computeRequirementIcon(requirementStatus: boolean) {
+    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
+  }
+
+  @observe('change.labels.*')
+  _computeLabels(
+    labelsRecord: PolymerDeepPropertyChange<
+      LabelNameToInfoMap,
+      LabelNameToInfoMap
+    >
+  ) {
+    const labels = labelsRecord.base;
+    this._optionalLabels = [];
+    this._requiredLabels = [];
+
+    for (const label of Object.keys(labels || {}).sort()) {
+      if (!hasOwnProperty(labels, label)) {
+        continue;
+      }
+
+      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});
+    }
+  }
+
+  /**
+   * @return The icon name, or undefined if no icon should
+   * be used.
+   */
+  _computeLabelIcon(labelInfo: QuickLabelInfo) {
+    if (labelInfo.approved) {
+      return 'gr-icons:check';
+    }
+    if (labelInfo.rejected) {
+      return 'gr-icons:close';
+    }
+    return 'gr-icons:schedule';
+  }
+
+  _computeLabelClass(labelInfo: QuickLabelInfo) {
+    if (labelInfo.approved) {
+      return 'approved';
+    }
+    if (labelInfo.rejected) {
+      return 'rejected';
+    }
+    return '';
+  }
+
+  _computeShowOptional(
+    optionalFieldsRecord: PolymerDeepPropertyChange<Label[], Label[]>
+  ) {
+    return optionalFieldsRecord.base.length ? '' : 'hidden';
+  }
+
+  _computeLabelValue(value: number) {
+    return `${value > 0 ? '+' : ''}${value}`;
+  }
+
+  _computeShowHideIcon(showOptionalLabels: boolean) {
+    return showOptionalLabels ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+  }
+
+  _computeSectionClass(show: boolean) {
+    return show ? '' : 'hidden';
+  }
+
+  _handleShowHide() {
+    this._showOptionalLabels = !this._showOptionalLabels;
+  }
+
+  _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
+    return `submit-requirement-item-${item.type}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-requirements': 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
deleted file mode 100644
index 0da31de..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
new file mode 100644
index 0000000..ef71314
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -0,0 +1,190 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: table;
+      width: 100%;
+    }
+    .status {
+      color: #ffa62f;
+      display: inline-block;
+      text-align: center;
+      vertical-align: top;
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+    }
+    .approved.status {
+      color: var(--positive-green-text-color);
+    }
+    .rejected.status {
+      color: var(--negative-red-text-color);
+    }
+    iron-icon {
+      color: inherit;
+    }
+    .status iron-icon {
+      vertical-align: top;
+    }
+    gr-endpoint-decorator.submit-requirement-endpoints,
+    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);
+    }
+    gr-endpoint-param {
+      display: none;
+    }
+  </style>
+  <template is="dom-repeat" items="[[_requirements]]">
+    <gr-endpoint-decorator
+      class="submit-requirement-endpoints"
+      name$="[[_computeSubmitRequirementEndpoint(item)]]"
+    >
+      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+      <gr-endpoint-param name="requirement" value="[[item]]">
+      </gr-endpoint-param>
+      <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="25"
+          tooltip="[[item.tooltip]]"
+          text="[[item.fallback_text]]"
+        ></gr-limited-text>
+      </div>
+      <div class="value">
+        <gr-endpoint-slot name="value"></gr-endpoint-slot>
+      </div>
+    </gr-endpoint-decorator>
+  </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="25"
+          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="25"
+          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
deleted file mode 100644
index e100f91..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ /dev/null
@@ -1,236 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-change-requirements></gr-change-requirements>
-  </template>
-</test-fixture>
-
-<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.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-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
new file mode 100644
index 0000000..c2fc72d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
@@ -0,0 +1,222 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-requirements.js';
+import {isHidden} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-change-requirements');
+
+suite('gr-change-metadata tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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:schedule');
+  });
+
+  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:schedule');
+
+    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:schedule');
+    assert.equal(element._optionalLabels[0].style, '');
+    assert.ok(element._optionalLabels[0].labelInfo);
+  });
+
+  test('optional show/hide', () => {
+    element._optionalLabels = [{label: 'test'}];
+    flush();
+
+    assert.ok(element.shadowRoot
+        .querySelector('section.optional'));
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.showHide'));
+    flush();
+
+    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: [],
+    };
+    flush();
+
+    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,
+        },
+      },
+    };
+    flush();
+
+    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,
+    };
+    flush();
+
+    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',
+      }],
+    };
+    flush();
+
+    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',
+      }],
+    };
+    flush();
+
+    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',
+      }],
+    };
+    flush();
+
+    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',
+      }],
+    };
+    flush();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.strictEqual(requirement.querySelector('.approved'), null);
+  });
+});
+
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
deleted file mode 100644
index 7ce6990..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ /dev/null
@@ -1,2274 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-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 CHANGE_ID_ERROR = {
-  MISMATCH: 'mismatch',
-  MISSING: 'missing',
-};
-const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
-
-const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-const DEFAULT_NUM_FILES_SHOWN = 200;
-
-const REVIEWERS_REGEX = /^(R|CC)=/gm;
-const MIN_CHECK_INTERVAL_SECS = 0;
-
-// 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';
-
-// 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 SMALL_RELATED_HEIGHT = 400;
-
-const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
-
-const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
-
-const MSG_PREFIX = '#message-';
-
-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 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 {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      /** @type {?} */
-      viewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-        observer: '_viewStateChanged',
-      },
-      backPage: String,
-      hasParent: Boolean,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      disableEdit: {
-        type: Boolean,
-        value: false,
-      },
-      disableDiffPrefs: {
-        type: Boolean,
-        value: false,
-      },
-      _diffPrefsDisabled: {
-        type: Boolean,
-        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-      },
-      _commentThreads: Array,
-      // 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,
-        observer: '_startUpdateCheckTimer',
-      },
-      _diffPrefs: Object,
-      _numFilesShown: {
-        type: Number,
-        value: DEFAULT_NUM_FILES_SHOWN,
-        observer: '_numFilesShownChanged',
-      },
-      _account: {
-        type: Object,
-        value: {},
-      },
-      _prefs: Object,
-      /** @type {?} */
-      _changeComments: Object,
-      _canStartReview: {
-        type: Boolean,
-        computed: '_computeCanStartReview(_change)',
-      },
-      _comments: Object,
-      /** @type {?} */
-      _change: {
-        type: Object,
-        observer: '_changeChanged',
-      },
-      _revisionInfo: {
-        type: Object,
-        computed: '_getRevisionInfo(_change)',
-      },
-      /** @type {?} */
-      _commitInfo: Object,
-      _currentRevision: {
-        type: Object,
-        computed: '_computeCurrentRevision(_change.current_revision, ' +
-          '_change.revisions)',
-        observer: '_handleCurrentRevisionUpdate',
-      },
-      _files: Object,
-      _changeNum: String,
-      _diffDrafts: {
-        type: Object,
-        value() { return {}; },
-      },
-      _editingCommitMessage: {
-        type: Boolean,
-        value: false,
-      },
-      _hideEditCommitMessage: {
-        type: Boolean,
-        computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-            '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
-            '_commitCollapsible)',
-      },
-      _diffAgainst: String,
-      /** @type {?string} */
-      _latestCommitMessage: {
-        type: String,
-        value: '',
-      },
-      _constants: {
-        type: Object,
-        value: {
-          SecondaryTabs,
-          PrimaryTabs,
-        },
-      },
-      _messages: {
-        type: Object,
-        value: {
-          NO_ROBOT_COMMENTS_THREADS_MSG,
-        },
-      },
-      _lineHeight: Number,
-      _changeIdCommitMessageError: {
-        type: String,
-        computed:
-        '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
-      },
-      /** @type {?} */
-      _patchRange: {
-        type: Object,
-      },
-      _filesExpanded: String,
-      _basePatchNum: String,
-      _selectedRevision: Object,
-      _currentRevisionActions: Object,
-      _allPatchSets: {
-        type: Array,
-        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: Boolean,
-      /** @type {?} */
-      _projectConfig: Object,
-      _replyButtonLabel: {
-        type: String,
-        value: 'Reply',
-        computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
-      },
-      _selectedPatchSet: String,
-      _shownFileCount: Number,
-      _initialLoadComplete: {
-        type: Boolean,
-        value: false,
-      },
-      _replyDisabled: {
-        type: Boolean,
-        value: true,
-        computed: '_computeReplyDisabled(_serverConfig)',
-      },
-      _changeStatus: {
-        type: String,
-        computed: 'changeStatusString(_change)',
-      },
-      _changeStatuses: {
-        type: String,
-        computed:
-        '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
-      },
-      /** If false, then the "Show more" button was used to expand. */
-      _commitCollapsed: {
-        type: Boolean,
-        value: true,
-      },
-      /** Is the "Show more/less" button visible? */
-      _commitCollapsible: {
-        type: Boolean,
-        computed: '_computeCommitCollapsible(_latestCommitMessage)',
-      },
-      _relatedChangesCollapsed: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {?number} */
-      _updateCheckTimerHandle: Number,
-      _editMode: {
-        type: Boolean,
-        computed: '_computeEditMode(_patchRange.*, params.*)',
-      },
-      _showRelatedToggle: {
-        type: Boolean,
-        value: false,
-        observer: '_updateToggleContainerClass',
-      },
-      _parentIsCurrent: {
-        type: Boolean,
-        computed: '_isParentCurrent(_currentRevisionActions)',
-      },
-      _submitEnabled: {
-        type: Boolean,
-        computed: '_isSubmitEnabled(_currentRevisionActions)',
-      },
-
-      /** @type {?} */
-      _mergeable: {
-        type: Boolean,
-        value: undefined,
-      },
-      _showFileTabContent: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {Array<string>} */
-      _dynamicTabHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicTabContentEndpoints: {
-        type: Array,
-      },
-      // 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,
-      },
-
-      /**
-       * @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,
-      },
-    };
-  }
-
-  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',
-    };
-  }
-
-  constructor() {
-    super();
-    this.flagsService = appContext.flagsService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-
-    this.addEventListener('topic-changed',
-        () => this._handleTopicChanged());
-
-    this.addEventListener(
-        // When an overlay is opened in a mobile viewport, the overlay has a full
-        // screen view. When it has a full screen view, we do not want the
-        // background to be scrollable. This will eliminate background scroll by
-        // hiding most of the contents on the screen upon opening, and showing
-        // again upon closing.
-        'fullscreen-overlay-opened',
-        () => this._handleHideBackgroundContent());
-
-    this.addEventListener('fullscreen-overlay-closed',
-        () => this._handleShowBackgroundContent());
-
-    this.addEventListener('diff-comments-modified',
-        () => this._handleReloadCommentThreads());
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-    });
-
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.$.restAPI.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-      this._setDiffViewMode();
-    });
-
-    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));
-
-    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');
-
-    this.addEventListener('show-primary-tab',
-        e => this._setActivePrimaryTab(e));
-    this.addEventListener('show-secondary-tab',
-        e => this._setActiveSecondaryTab(e));
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'scroll', '_handleScroll');
-    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-
-    if (this._updateCheckTimerHandle) {
-      this._cancelUpdateCheckTimer();
-    }
-  }
-
-  _isChangeLogExperimentEnabled() {
-    return this.flagsService.isEnabled('UiFeature__cleaner_changelog');
-  }
-
-  get messagesList() {
-    const tagName = this._isChangeLogExperimentEnabled()
-      ? 'gr-messages-list-experimental' : 'gr-messages-list';
-    return this.shadowRoot.querySelector(tagName);
-  }
-
-  get threadList() {
-    return this.shadowRoot.querySelector('gr-thread-list');
-  }
-
-  /**
-   * @param {boolean=} opt_reset
-   */
-  _setDiffViewMode(opt_reset) {
-    if (!opt_reset && this.viewState.diffViewMode) { return; }
-
-    return this._getPreferences()
-        .then( prefs => {
-          if (!this.viewState.diffMode) {
-            this.set('viewState.diffMode', prefs.default_diff_view);
-          }
-        })
-        .then(() => {
-          if (!this.viewState.diffMode) {
-            this.set('viewState.diffMode', 'SIDE_BY_SIDE');
-          }
-        });
-  }
-
-  _onOpenFixPreview(e) {
-    this.$.applyFixDialog.open(e);
-  }
-
-  _onCloseFixPreview(e) {
-    this._reload();
-  }
-
-  _handleToggleDiffMode(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
-    } else {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
-    }
-  }
-
-  _isTabActive(tab, activeTabs) {
-    return activeTabs.includes(tab);
-  }
-
-  /**
-   * 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 (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;
-      }
-
-      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();
-    GerritNav.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.
-    GerritNav.navigateToRelativeUrl(this.backPage ||
-         GerritNav.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().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;
-        });
-  }
-
-  _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 &&
-      this._patchRange &&
-      this._patchRange.patchNum === this.EDIT_NAME &&
-      change.status === this.ChangeStatus.NEW
-    ) {
-      /* eslint-disable max-len */
-      const message = 'Change edit not found. Please create a change edit.';
-      this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-      );
-      GerritNav.navigateToChange(change);
-      return;
-    }
-
-    if (
-      !edit &&
-      (change.status === this.ChangeStatus.MERGED ||
-        change.status === this.ChangeStatus.ABANDONED) &&
-      this._editMode
-    ) {
-      /* eslint-disable max-len */
-      const message =
-        'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.';
-      this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-      );
-      GerritNav.navigateToChange(change);
-      return;
-    }
-
-    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 => {
-                    // 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() {
-    // We only call _getEdit if the patchset number is an edit.
-    // We have to do this to ensure we can tell if an edit
-    // exists or not.
-    // This safely works even if a edit does not exist.
-    if (this._patchRange.patchNum === this.EDIT_NAME) {
-      return this._getEdit().then(edit => {
-        if (!edit) {
-          return Promise.resolve();
-        }
-
-        return this._getChangeCommitInfo();
-      });
-    }
-
-    return this._getChangeCommitInfo();
-  }
-
-  _getChangeCommitInfo() {
-    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();
-    }
-
-    // If mergeable bit was already returned in detail REST endpoint, use it.
-    if (this._change.mergeable !== undefined) {
-      this._mergeable = this._change.mergeable;
-      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;
-        }
-
-        if (!toastMessage) {
-          this._startUpdateCheckTimer();
-          return;
-        }
-
-        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.
-              GerritNav.navigateToChange(this._change);
-            }.bind(this),
-          },
-          composed: true, bubbles: true,
-        }));
-      });
-    }, this._serverConfig.change.update_delay * 1000);
-  }
-
-  _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();
-    }
-  }
-
-  _handleTopicChanged() {
-    this.$.relatedChanges.reload();
-  }
-
-  _computeHeaderClass(editMode) {
-    const classes = ['header'];
-    if (editMode) { classes.push('editMode'); }
-    return classes.join(' ');
-  }
-
-  _computeEditMode(patchRangeRecord, paramsRecord) {
-    if ([patchRangeRecord, paramsRecord].some(arg => arg === undefined)) {
-      return undefined;
-    }
-
-    if (paramsRecord.base && paramsRecord.base.edit) { return true; }
-
-    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:
-        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}`;
-  }
-
-  _patchNumChanged(patchNumStr) {
-    if (!this._selectedRevision) {
-      return;
-    }
-
-    let patchNum = parseInt(patchNumStr, 10);
-    if (patchNumStr === 'edit') {
-      patchNum = patchNumStr;
-    }
-
-    if (patchNum === this._selectedRevision._number) {
-      return;
-    }
-    this._selectedRevision = Object.values(this._change.revisions).find(
-        revision => revision._number === patchNum);
-  }
-
-  /**
-   * If an edit exists already, load it. Otherwise, toggle edit mode via the
-   * navigation API.
-   */
-  _handleEditTap() {
-    const editInfo = Object.values(this._change.revisions).find(info =>
-      info._number === this.EDIT_NAME);
-
-    if (editInfo) {
-      GerritNav.navigateToChange(this._change, this.EDIT_NAME);
-      return;
-    }
-
-    // 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);
-  }
-
-  _handleStopEditTap() {
-    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
-  }
-
-  _resetReplyOverlayFocusStops() {
-    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
-  }
-
-  _handleToggleStar(e) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number,
-        e.detail.starred);
-  }
-
-  _getRevisionInfo(change) {
-    return new 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.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
new file mode 100644
index 0000000..8a0bf0f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -0,0 +1,2846 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/paper-tabs/paper-tabs';
+import '../../../styles/shared-styles';
+import '../../diff/gr-comment-api/gr-comment-api';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-change-star/gr-change-star';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-content/gr-editable-content';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../gr-change-actions/gr-change-actions';
+import '../gr-change-metadata/gr-change-metadata';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-download-dialog/gr-download-dialog';
+import '../gr-file-list-header/gr-file-list-header';
+import '../gr-included-in-dialog/gr-included-in-dialog';
+import '../gr-messages-list/gr-messages-list';
+import '../gr-related-changes-list/gr-related-changes-list';
+import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-reply-dialog/gr-reply-dialog';
+import '../gr-thread-list/gr-thread-list';
+import '../gr-upload-help-dialog/gr-upload-help-dialog';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-view_html';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrEditConstants} from '../../edit/gr-edit-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
+import {appContext} from '../../../services/app-context';
+import {ChangeStatus} from '../../../constants/constants';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  fetchChangeUpdates,
+  hasEditBasedOnCurrentPatchSet,
+  hasEditPatchsetLoaded,
+  patchNumEquals,
+  PatchSet,
+} from '../../../utils/patch-set-util';
+import {changeStatuses, changeStatusString} from '../../../utils/change-util';
+import {EventType} from '../../plugins/gr-plugin-types';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {
+  changeIsAbandoned,
+  changeIsMerged,
+  changeIsOpen,
+} from '../../../utils/change-util';
+import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
+import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
+import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+  NumericChangeId,
+  PatchRange,
+  ActionNameToActionInfoMap,
+  CommitId,
+  PatchSetNum,
+  ParentPatchSetNum,
+  EditPatchSetNum,
+  ServerInfo,
+  ConfigInfo,
+  PreferencesInfo,
+  CommitInfo,
+  DiffPreferencesInfo,
+  RevisionInfo,
+  EditInfo,
+  LabelNameToInfoMap,
+  UrlEncodedCommentId,
+  QuickLabelInfo,
+  ApprovalInfo,
+  ElementPropertyDeepChange,
+} from '../../../types/common';
+import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
+import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
+import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
+import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
+import {
+  GrCommentApi,
+  ChangeComments,
+} from '../../diff/gr-comment-api/gr-comment-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {
+  CommentThread,
+  UIDraft,
+  DraftInfo,
+  isDraftThread,
+  isRobot,
+} from '../../../utils/comment-util';
+import {
+  PolymerDeepPropertyChange,
+  PolymerSpliceChange,
+  PolymerSplice,
+} from '@polymer/polymer/interfaces';
+import {AppElementChangeViewParams} from '../../gr-app-types';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  GrFileList,
+  DEFAULT_NUM_FILES_SHOWN,
+} from '../gr-file-list/gr-file-list';
+import {ChangeViewState, isPolymerSpliceChange} from '../../../types/types';
+import {
+  CustomKeyboardEvent,
+  EditableContentSaveEvent,
+  OpenFixPreviewEvent,
+  ShowAlertEventDetail,
+  SwitchTabEvent,
+} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+
+const CHANGE_ID_ERROR = {
+  MISMATCH: 'mismatch',
+  MISSING: 'missing',
+};
+const CHANGE_ID_REGEX_PATTERN = /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
+
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+
+const REVIEWERS_REGEX = /^(R|CC)=/gm;
+const MIN_CHECK_INTERVAL_SECS = 0;
+
+// 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';
+
+// 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 SMALL_RELATED_HEIGHT = 400;
+
+const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
+
+const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
+
+const MSG_PREFIX = '#message-';
+
+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',
+};
+
+enum 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;
+
+export interface GrChangeView {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: GrJsApiInterface;
+    commentAPI: GrCommentApi;
+    applyFixDialog: GrApplyFixDialog;
+    fileList: GrFileList & Element;
+    fileListHeader: GrFileListHeader;
+    commitMessageEditor: GrEditableContent;
+    includedInOverlay: GrOverlay;
+    includedInDialog: GrIncludedInDialog;
+    downloadOverlay: GrOverlay;
+    downloadDialog: GrDownloadDialog;
+    uploadHelpOverlay: GrOverlay;
+    replyOverlay: GrOverlay;
+    replyDialog: GrReplyDialog;
+    mainContent: HTMLDivElement;
+    relatedChanges: GrRelatedChangesList;
+    changeStar: GrChangeStar;
+    actions: GrChangeActions;
+    commitMessage: HTMLDivElement;
+    commitAndRelated: HTMLDivElement;
+    metadata: GrChangeMetadata;
+    relatedChangesToggle: HTMLDivElement;
+    mainChangeInfo: HTMLDivElement;
+    commitCollapseToggleButton: GrButton;
+    commitCollapseToggle: HTMLDivElement;
+    relatedChangesToggleButton: GrButton;
+    replyBtn: GrButton;
+  };
+}
+
+export type ChangeViewPatchRange = Partial<PatchRange>;
+
+@customElement('gr-change-view')
+export class GrChangeView extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * 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
+   */
+
+  reporting = appContext.reportingService;
+
+  /**
+   * URL params passed from the router.
+   */
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementChangeViewParams;
+
+  @property({type: Object, notify: true, observer: '_viewStateChanged'})
+  viewState: Partial<ChangeViewState> = {};
+
+  @property({type: String})
+  backPage?: string;
+
+  @property({type: Boolean})
+  hasParent?: boolean;
+
+  @property({type: Object})
+  keyEventTarget = document.body;
+
+  @property({type: Boolean})
+  disableEdit = false;
+
+  @property({type: Boolean})
+  disableDiffPrefs = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+  })
+  _diffPrefsDisabled?: boolean;
+
+  @property({type: Array})
+  _commentThreads?: CommentThread[];
+
+  // TODO(taoalpha): Consider replacing diffDrafts
+  // with _draftCommentThreads everywhere, currently only
+  // replaced in reply-dialog
+  @property({type: Array})
+  _draftCommentThreads?: CommentThread[];
+
+  @property({
+    type: Array,
+    computed:
+      '_computeRobotCommentThreads(_commentThreads,' +
+      ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
+  })
+  _robotCommentThreads?: CommentThread[];
+
+  @property({type: Object, observer: '_startUpdateCheckTimer'})
+  _serverConfig?: ServerInfo;
+
+  @property({type: Object})
+  _diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Number, observer: '_numFilesShownChanged'})
+  _numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Object})
+  _account?: AccountDetailInfo;
+
+  @property({type: Object})
+  _prefs?: PreferencesInfo;
+
+  @property({type: Object})
+  _changeComments?: ChangeComments;
+
+  @property({type: Boolean, computed: '_computeCanStartReview(_change)'})
+  _canStartReview?: boolean;
+
+  @property({type: Object, observer: '_changeChanged'})
+  _change?: ChangeInfo | ParsedChangeInfo;
+
+  @property({type: Object, computed: '_getRevisionInfo(_change)'})
+  _revisionInfo?: RevisionInfoClass;
+
+  @property({type: Object})
+  _commitInfo?: CommitInfo;
+
+  @property({
+    type: Object,
+    computed:
+      '_computeCurrentRevision(_change.current_revision, ' +
+      '_change.revisions)',
+    observer: '_handleCurrentRevisionUpdate',
+  })
+  _currentRevision?: RevisionInfo;
+
+  @property({type: String})
+  _changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  _diffDrafts?: {[path: string]: UIDraft[]} = {};
+
+  @property({type: Boolean})
+  _editingCommitMessage = false;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeHideEditCommitMessage(_loggedIn, ' +
+      '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
+      '_commitCollapsible)',
+  })
+  _hideEditCommitMessage?: boolean;
+
+  @property({type: String})
+  _diffAgainst?: string;
+
+  @property({type: String})
+  _latestCommitMessage: string | null = '';
+
+  @property({type: Object})
+  _constants = {
+    SecondaryTab,
+    PrimaryTab,
+  };
+
+  @property({type: Object})
+  _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
+
+  @property({type: Number})
+  _lineHeight?: number;
+
+  @property({
+    type: String,
+    computed:
+      '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
+  })
+  _changeIdCommitMessageError?: string;
+
+  @property({type: Object})
+  _patchRange?: ChangeViewPatchRange;
+
+  @property({type: String})
+  _filesExpanded?: string;
+
+  @property({type: String})
+  _basePatchNum?: string;
+
+  @property({type: Object})
+  _selectedRevision?: RevisionInfo | EditRevisionInfo;
+
+  @property({type: Object})
+  _currentRevisionActions?: ActionNameToActionInfoMap;
+
+  @property({
+    type: Array,
+    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+  })
+  _allPatchSets?: PatchSet[];
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Boolean})
+  _loading?: boolean;
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({
+    type: String,
+    computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+  })
+  _replyButtonLabel = 'Reply';
+
+  @property({type: String})
+  _selectedPatchSet?: string;
+
+  @property({type: Number})
+  _shownFileCount?: number;
+
+  @property({type: Boolean})
+  _initialLoadComplete = false;
+
+  @property({type: Boolean})
+  _replyDisabled = true;
+
+  @property({type: String, computed: '_changeStatusString(_change)'})
+  _changeStatus?: string;
+
+  @property({
+    type: String,
+    computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
+  })
+  _changeStatuses?: string[];
+
+  /** If false, then the "Show more" button was used to expand. */
+  @property({type: Boolean})
+  _commitCollapsed = true;
+
+  /** Is the "Show more/less" button visible? */
+  @property({
+    type: Boolean,
+    computed: '_computeCommitCollapsible(_latestCommitMessage)',
+  })
+  _commitCollapsible?: boolean;
+
+  @property({type: Boolean})
+  _relatedChangesCollapsed = true;
+
+  @property({type: Number})
+  _updateCheckTimerHandle?: number | null;
+
+  @property({
+    type: Boolean,
+    computed: '_computeEditMode(_patchRange.*, params.*)',
+  })
+  _editMode?: boolean;
+
+  @property({type: Boolean, observer: '_updateToggleContainerClass'})
+  _showRelatedToggle = false;
+
+  @property({
+    type: Boolean,
+    computed: '_isParentCurrent(_currentRevisionActions)',
+  })
+  _parentIsCurrent?: boolean;
+
+  @property({
+    type: Boolean,
+    computed: '_isSubmitEnabled(_currentRevisionActions)',
+  })
+  _submitEnabled?: boolean;
+
+  @property({type: Boolean})
+  _mergeable: boolean | null = null;
+
+  @property({type: Boolean})
+  _showFileTabContent = true;
+
+  @property({type: Array})
+  _dynamicTabHeaderEndpoints: string[] = [];
+
+  @property({type: Array})
+  _dynamicTabContentEndpoints: string[] = [];
+
+  @property({type: String})
+  // The dynamic content of the plugin added tab
+  _selectedTabPluginEndpoint?: string;
+
+  @property({type: String})
+  // The dynamic heading of the plugin added tab
+  _selectedTabPluginHeader?: string;
+
+  @property({
+    type: Array,
+    computed:
+      '_computeRobotCommentsPatchSetDropdownItems(_change, _commentThreads)',
+  })
+  _robotCommentsPatchSetDropdownItems: DropdownLink[] = [];
+
+  @property({type: Number})
+  _currentRobotCommentsPatchSet?: PatchSetNum;
+
+  /**
+   * this is a two-element tuple to always
+   * hold the current active tab for both primary and secondary tabs
+   */
+  @property({type: Array})
+  _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
+
+  @property({type: Boolean})
+  _showAllRobotComments = false;
+
+  @property({type: Boolean})
+  _showRobotCommentsButton = false;
+
+  _throttledToggleChangeStar?: EventListener;
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+      [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
+      [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
+      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
+      [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+      [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+      [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
+      [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+      [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+    };
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    this._throttledToggleChangeStar = this._throttleWrap(e =>
+      this._handleToggleChangeStar(e as CustomKeyboardEvent)
+    );
+  }
+
+  /** @override */
+  created() {
+    super.created();
+
+    this.addEventListener('topic-changed', () => this._handleTopicChanged());
+
+    this.addEventListener(
+      // When an overlay is opened in a mobile viewport, the overlay has a full
+      // screen view. When it has a full screen view, we do not want the
+      // background to be scrollable. This will eliminate background scroll by
+      // hiding most of the contents on the screen upon opening, and showing
+      // again upon closing.
+      'fullscreen-overlay-opened',
+      () => this._handleHideBackgroundContent()
+    );
+
+    this.addEventListener('fullscreen-overlay-closed', () =>
+      this._handleShowBackgroundContent()
+    );
+
+    this.addEventListener('diff-comments-modified', () =>
+      this._handleReloadCommentThreads()
+    );
+
+    this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getServerConfig().then(config => {
+      this._serverConfig = config;
+      this._replyDisabled = false;
+    });
+
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.$.restAPI.getAccount().then(acct => {
+          this._account = acct;
+        });
+      }
+      this._setDiffViewMode();
+    });
+
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicTabHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-tab-header'
+        );
+        this._dynamicTabContentEndpoints = getPluginEndpoints().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));
+
+    this.addEventListener('comment-save', e => this._handleCommentSave(e));
+    this.addEventListener('comment-refresh', () => this._reloadDrafts());
+    this.addEventListener('comment-discard', e =>
+      this._handleCommentDiscard(e)
+    );
+    this.addEventListener('change-message-deleted', () => this._reload());
+    this.addEventListener('editable-content-save', e =>
+      this._handleCommitMessageSave(e)
+    );
+    this.addEventListener('editable-content-cancel', () =>
+      this._handleCommitMessageCancel()
+    );
+    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+    this.addEventListener('close-fix-preview', () => this._onCloseFixPreview());
+    this.listen(window, 'scroll', '_handleScroll');
+    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+
+    this.addEventListener('show-primary-tab', e =>
+      this._setActivePrimaryTab(e)
+    );
+    this.addEventListener('show-secondary-tab', e =>
+      this._setActiveSecondaryTab(e)
+    );
+    this.addEventListener('reload', e => {
+      e.stopPropagation();
+      this._reload(
+        /* isLocationChange= */ false,
+        /* clearPatchset= */ e.detail && e.detail.clearPatchset
+      );
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_handleScroll');
+    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+
+    if (this._updateCheckTimerHandle) {
+      this._cancelUpdateCheckTimer();
+    }
+  }
+
+  get messagesList(): GrMessagesList | null {
+    return this.shadowRoot!.querySelector('gr-messages-list');
+  }
+
+  get threadList(): GrThreadList | null {
+    return this.shadowRoot!.querySelector('gr-thread-list');
+  }
+
+  _changeStatusString(change: ChangeInfo) {
+    return changeStatusString(change);
+  }
+
+  _setDiffViewMode(opt_reset?: boolean) {
+    if (!opt_reset && this.viewState.diffViewMode) {
+      return;
+    }
+
+    return this._getPreferences()
+      .then(prefs => {
+        if (!this.viewState.diffMode && prefs) {
+          this.set('viewState.diffMode', prefs.default_diff_view);
+        }
+      })
+      .then(() => {
+        if (!this.viewState.diffMode) {
+          this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+        }
+      });
+  }
+
+  _onOpenFixPreview(e: OpenFixPreviewEvent) {
+    this.$.applyFixDialog.open(e);
+  }
+
+  _onCloseFixPreview() {
+    this._reload();
+  }
+
+  _handleToggleDiffMode(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+    } else {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+    }
+  }
+
+  _isTabActive(tab: string, activeTabs: string[]) {
+    return activeTabs.includes(tab);
+  }
+
+  /**
+   * Actual implementation of switching a tab
+   *
+   * @param paperTabs - the parent tabs container
+   */
+  _setActiveTab(
+    paperTabs: PaperTabsElement,
+    activeDetails: {
+      activeTabName?: string;
+      activeTabIndex?: number;
+      scrollIntoView?: boolean;
+    }
+  ) {
+    const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
+    const tabs = paperTabs.querySelectorAll('paper-tab') as NodeListOf<
+      HTMLElement
+    >;
+    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 (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.
+   */
+  _setActivePrimaryTab(e: SwitchTabEvent) {
+    const primaryTabs = this.shadowRoot!.querySelector(
+      '#primaryTabs'
+    ) as PaperTabsElement;
+    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.
+   */
+  _setActiveSecondaryTab(e: SwitchTabEvent) {
+    const secondaryTabs = this.shadowRoot!.querySelector(
+      '#secondaryTabs'
+    ) as PaperTabsElement;
+    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: EditableContentSaveEvent) {
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    // 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(() => {
+        this.$.commitMessageEditor.disabled = false;
+      });
+  }
+
+  _reloadWindow() {
+    window.location.reload();
+  }
+
+  _handleCommitMessageCancel() {
+    this._editingCommitMessage = false;
+  }
+
+  _computeChangeStatusChips(
+    change: ChangeInfo | undefined,
+    mergeable: boolean | null,
+    submitEnabled?: boolean
+  ) {
+    if (!change) {
+      return undefined;
+    }
+
+    // Show no chips until mergeability is loaded.
+    if (mergeable === null) {
+      return [];
+    }
+
+    const options = {
+      includeDerived: true,
+      mergeable: !!mergeable,
+      submitEnabled: !!submitEnabled,
+    };
+    return changeStatuses(change, options);
+  }
+
+  _computeHideEditCommitMessage(
+    loggedIn: boolean,
+    editing: boolean,
+    change: ChangeInfo,
+    editMode?: boolean,
+    collapsed?: boolean,
+    collapsible?: boolean
+  ) {
+    if (
+      !loggedIn ||
+      editing ||
+      (change && change.status === ChangeStatus.MERGED) ||
+      editMode ||
+      (collapsed && collapsible)
+    ) {
+      return true;
+    }
+
+    return false;
+  }
+
+  _robotCommentCountPerPatchSet(threads: CommentThread[]) {
+    return threads.reduce((robotCommentCountMap, thread) => {
+      const comments = thread.comments;
+      const robotCommentsCount = comments.reduce(
+        (acc, comment) => (isRobot(comment) ? acc + 1 : acc),
+        0
+      );
+      if (comments[0].patch_set)
+        robotCommentCountMap[`${comments[0].patch_set}`] =
+          (robotCommentCountMap[`${comments[0].patch_set}`] || 0) +
+          robotCommentsCount;
+      return robotCommentCountMap;
+    }, {} as {[patchset: string]: number});
+  }
+
+  _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
+    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: ChangeInfo,
+    commentThreads: CommentThread[]
+  ) {
+    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 as number) - (a.value as number));
+  }
+
+  _handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
+    this._currentRobotCommentsPatchSet = currentRevision._number;
+  }
+
+  _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
+    const patchSet = Number(e.detail.value) as PatchSetNum;
+    if (patchSet === this._currentRobotCommentsPatchSet) return;
+    this._currentRobotCommentsPatchSet = patchSet;
+  }
+
+  _computeShowText(showAllRobotComments: boolean) {
+    return showAllRobotComments ? 'Show Less' : 'Show more';
+  }
+
+  _toggleShowRobotComments() {
+    this._showAllRobotComments = !this._showAllRobotComments;
+  }
+
+  _computeRobotCommentThreads(
+    commentThreads: CommentThread[],
+    currentRobotCommentsPatchSet: PatchSetNum,
+    showAllRobotComments: boolean
+  ) {
+    if (!commentThreads || !currentRobotCommentsPatchSet) return [];
+    const threads = commentThreads.filter(thread => {
+      const comments = thread.comments || [];
+      return (
+        comments.length &&
+        isRobot(comments[0]) &&
+        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();
+      flush();
+    });
+  }
+
+  _handleReloadDiffComments(
+    e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
+  ) {
+    // 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: number,
+    changeComments: 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: CustomEvent<{comment: DraftInfo}>) {
+    const draft = e.detail.comment;
+    if (!draft.__draft || !draft.path) return;
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+
+    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 = {...this._diffDrafts};
+    if (!diffDrafts[draft.path]) {
+      diffDrafts[draft.path] = [draft];
+      this._diffDrafts = diffDrafts;
+      return;
+    }
+    for (let i = 0; i < diffDrafts[draft.path].length; i++) {
+      if (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: CustomEvent<{comment: DraftInfo}>) {
+    const draft = e.detail.comment;
+    if (!draft.__draft || !draft.path) {
+      return;
+    }
+
+    if (!this._diffDrafts || !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;
+    }
+
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    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 = {...this._diffDrafts};
+    diffDrafts[draft.path].splice(index, 1);
+    if (diffDrafts[draft.path].length === 0) {
+      delete diffDrafts[draft.path];
+    }
+    this._diffDrafts = diffDrafts;
+  }
+
+  _handleReplyTap(e: MouseEvent) {
+    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() {
+    this.$.includedInOverlay.close();
+  }
+
+  _handleOpenDownloadDialog() {
+    this.$.downloadOverlay.open().then(() => {
+      this.$.downloadOverlay.setFocusStops(
+        this.$.downloadDialog.getFocusStops()
+      );
+      this.$.downloadDialog.focus();
+    });
+  }
+
+  _handleDownloadDialogClose() {
+    this.$.downloadOverlay.close();
+  }
+
+  _handleOpenUploadHelpDialog() {
+    this.$.uploadHelpOverlay.open();
+  }
+
+  _handleCloseUploadHelpDialog() {
+    this.$.uploadHelpOverlay.close();
+  }
+
+  _handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
+    const msg: string = 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() {
+    this.addEventListener(
+      'change-details-loaded',
+      () => {
+        this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+      },
+      {once: true}
+    );
+    this.$.replyOverlay.close();
+    this._reload();
+  }
+
+  _handleReplyCancel() {
+    this.$.replyOverlay.close();
+  }
+
+  _handleReplyAutogrow() {
+    // 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: CustomEvent<{value: {ccsOnly: boolean}}>) {
+    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: CustomEvent<{length: number}>) {
+    this._shownFileCount = e.detail.length;
+  }
+
+  _expandAllDiffs(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    this.$.fileList.expandAllDiffs();
+  }
+
+  _collapseAllDiffs() {
+    this.$.fileList.collapseAllDiffs();
+  }
+
+  _paramsChanged(value: AppElementChangeViewParams) {
+    if (value.view !== GerritView.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);
+    const changeChanged = this._changeNum !== value.changeNum;
+
+    const patchRange: ChangeViewPatchRange = {
+      patchNum: value.patchNum,
+      basePatchNum: value.basePatchNum || ParentPatchSetNum,
+    };
+
+    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 (!changeChanged && patchChanged) {
+      if (!patchRange.patchNum) {
+        patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
+      }
+      this._reloadPatchNumDependentResources().then(() => {
+        this._sendShowChangeEvent();
+      });
+      return;
+    }
+
+    this._initialLoadComplete = false;
+    this._changeNum = value.changeNum;
+    this.$.relatedChanges.clear();
+
+    this._reload(true).then(() => {
+      this._performPostLoadTasks();
+    });
+
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._initActiveTabs(value);
+      });
+  }
+
+  _initActiveTabs(params?: AppElementChangeViewParams) {
+    let primaryTab = PrimaryTab.FILES;
+    if (params && params.queryMap && params.queryMap.has('tab')) {
+      primaryTab = params.queryMap.get('tab') as PrimaryTab;
+    }
+    this._setActivePrimaryTab(
+      new CustomEvent('initActiveTab', {
+        detail: {
+          tab: primaryTab,
+        },
+      })
+    );
+    this._setActiveSecondaryTab(
+      new CustomEvent('initActiveTab', {
+        detail: {
+          tab: SecondaryTab.CHANGE_LOG,
+        },
+      })
+    );
+  }
+
+  _sendShowChangeEvent() {
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
+      change: this._change,
+      patchNum: this._patchRange.patchNum,
+      info: {mergeable: this._mergeable},
+    });
+  }
+
+  _performPostLoadTasks() {
+    this._maybeShowReplyDialog();
+    this._maybeShowRevertDialog();
+    this._maybeShowDownloadDialog();
+
+    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;
+    });
+  }
+
+  @observe('params', '_change')
+  _paramsAndChangeChanged(
+    value?: AppElementChangeViewParams,
+    change?: ChangeInfo
+  ) {
+    // Polymer 2: check for undefined
+    if (!value || !change) {
+      return;
+    }
+
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    // 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 ||
+      patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+      patchRangeState.patchNum !== this._patchRange.patchNum
+    ) {
+      this._resetFileListViewState();
+    }
+  }
+
+  _viewStateChanged(viewState: ChangeViewState) {
+    this._numFilesShown = viewState.numFilesShown
+      ? viewState.numFilesShown
+      : DEFAULT_NUM_FILES_SHOWN;
+  }
+
+  _numFilesShownChanged(numFilesShown: number) {
+    this.viewState.numFilesShown = numFilesShown;
+  }
+
+  _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    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: string) {
+    if (hash.startsWith(MSG_PREFIX) && this.messagesList) {
+      this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
+    }
+  }
+
+  _getLocationSearch() {
+    // Not inlining to make it easier to test.
+    return window.location.search;
+  }
+
+  _getUrlParameter(param: string) {
+    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() {
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => this._getLoggedIn())
+      .then(loggedIn => {
+        if (
+          !loggedIn ||
+          !this._change ||
+          this._change.status !== 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);
+      }
+    });
+  }
+
+  _maybeShowDownloadDialog() {
+    if (this.viewState.showDownloadDialog) {
+      this._handleOpenDownloadDialog();
+      this.set('viewState.showDownloadDialog', 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?: ChangeInfo | ParsedChangeInfo) {
+    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 || 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`.
+   */
+  _getBasePatchNum(
+    change: ChangeInfo | ParsedChangeInfo,
+    patchRange: ChangeViewPatchRange
+  ) {
+    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 = hasOwnProperty(parentCounts, 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: ChangeInfo) {
+    return GerritNav.getUrlForChange(change);
+  }
+
+  _computeShowCommitInfo(changeStatus: string, current_revision: RevisionInfo) {
+    return changeStatus === 'Merged' && current_revision;
+  }
+
+  _computeMergedCommitInfo(
+    current_revision: CommitId,
+    revisions: {[revisionId: string]: RevisionInfo}
+  ) {
+    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: string) {
+    return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+  }
+
+  _computeTitleAttributeWarning(displayChangeId: string) {
+    if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
+      return 'Change-Id mismatch';
+    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+      return 'No Change-Id in commit message';
+    }
+    return undefined;
+  }
+
+  _computeChangeIdCommitMessageError(
+    commitMessage?: string,
+    change?: ChangeInfo
+  ) {
+    if (change === 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[2];
+    }
+
+    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;
+  }
+
+  _computeReplyButtonLabel(
+    changeRecord?: ElementPropertyDeepChange<
+      GrChangeView,
+      '_diffDrafts'
+    > | null,
+    canStartReview?: boolean
+  ) {
+    if (changeRecord === undefined || canStartReview === undefined) {
+      return 'Reply';
+    }
+
+    const drafts = (changeRecord && changeRecord.base) || {};
+    const draftCount = Object.keys(drafts).reduce(
+      (count, file) => count + drafts[file].length,
+      0
+    );
+
+    let label = canStartReview ? 'Start Review' : 'Reply';
+    if (draftCount > 0) {
+      label += ` (${draftCount})`;
+    }
+    return label;
+  }
+
+  _handleOpenReplyDialog(e: CustomKeyboardEvent) {
+    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;
+      }
+
+      e.preventDefault();
+      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+    });
+  }
+
+  _handleOpenDownloadDialogShortcut(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._handleOpenDownloadDialog();
+  }
+
+  _handleEditTopic(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.metadata.editTopic();
+  }
+
+  _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Base is already selected.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+  }
+
+  _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Left is already base.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
+  }
+
+  _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      const detail: ShowAlertEventDetail = {
+        message: 'Latest is already selected.',
+      };
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail,
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(
+      this._change,
+      latestPatchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Right is already latest.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(
+      this._change,
+      latestPatchNum,
+      this._patchRange.patchNum
+    );
+  }
+
+  _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (
+      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+    ) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Already diffing base against latest.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(this._change, latestPatchNum);
+  }
+
+  _handleRefreshChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    e.preventDefault();
+    this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
+  }
+
+  _handleToggleChangeStar(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this.$.changeStar.toggleStar();
+  }
+
+  _handleUpToDashboard(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._determinePageBack();
+  }
+
+  _handleExpandAllMessages(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.messagesList) {
+      this.messagesList.handleExpandCollapse(true);
+    }
+  }
+
+  _handleCollapseAllMessages(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.messagesList) {
+      this.messagesList.handleExpandCollapse(false);
+    }
+  }
+
+  _handleOpenDiffPrefsShortcut(e: CustomKeyboardEvent) {
+    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.
+    GerritNav.navigateToRelativeUrl(this.backPage || GerritNav.getUrlForRoot());
+  }
+
+  _handleLabelRemoved(
+    splices: Array<PolymerSplice<ApprovalInfo[]>>,
+    path: string
+  ) {
+    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) as QuickLabelInfo;
+        if (
+          labelDict.approved &&
+          labelDict.approved._account_id === removed._account_id
+        ) {
+          this._reload();
+          return;
+        }
+      }
+    }
+  }
+
+  @observe('_change.labels.*')
+  _labelsChanged(
+    changeRecord: PolymerDeepPropertyChange<
+      LabelNameToInfoMap,
+      PolymerSpliceChange<ApprovalInfo[]>
+    >
+  ) {
+    if (!changeRecord) {
+      return;
+    }
+    if (changeRecord.value && isPolymerSpliceChange(changeRecord.value)) {
+      this._handleLabelRemoved(
+        changeRecord.value.indexSplices,
+        changeRecord.path
+      );
+    }
+    this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
+      change: this._change,
+    });
+  }
+
+  _openReplyDialog(section?: FocusTarget) {
+    this.$.replyOverlay.open().finally(() => {
+      // the following code should be executed no matter open succeed or not
+      this._resetReplyOverlayFocusStops();
+      this.$.replyDialog.open(section);
+      flush();
+      this.$.replyOverlay.center();
+    });
+  }
+
+  _handleGetChangeDetailError(response?: Response | null) {
+    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) throw new Error('missing required change property');
+    return this.$.restAPI
+      .getProjectConfig(this._change.project)
+      .then(config => {
+        this._projectConfig = config;
+      });
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _prepareCommitMsgForLinkify(msg: string) {
+    // 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.
+   */
+  _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
+    if (
+      !edit &&
+      this._patchRange?.patchNum === EditPatchSetNum &&
+      changeIsOpen(change)
+    ) {
+      /* eslint-disable max-len */
+      const message = 'Change edit not found. Please create a change edit.';
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message},
+          bubbles: true,
+          composed: true,
+        })
+      );
+      GerritNav.navigateToChange(change);
+      return;
+    }
+
+    if (
+      !edit &&
+      (changeIsMerged(change) || changeIsAbandoned(change)) &&
+      this._editMode
+    ) {
+      const message =
+        'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.';
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message},
+          bubbles: true,
+          composed: true,
+        })
+      );
+      GerritNav.navigateToChange(change);
+      return;
+    }
+
+    if (!edit) return;
+
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+
+    if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
+    const changeWithEdit = change;
+    if (changeWithEdit.revisions)
+      changeWithEdit.revisions[edit.commit.commit] = {
+        _number: EditPatchSetNum,
+        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 &&
+      changeWithEdit.current_revision === edit.base_revision
+    ) {
+      changeWithEdit.current_revision = edit.commit.commit;
+      this.set('_patchRange.patchNum', EditPatchSetNum);
+      // 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
+      if (changeWithEdit.revisions) {
+        changeWithEdit.revisions[edit.commit.commit].actions =
+          changeWithEdit.revisions[edit.base_revision].actions;
+      }
+    }
+  }
+
+  _getChangeDetail() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    const detailCompletes = this.$.restAPI.getChangeDetail(this._changeNum, r =>
+      this._handleGetChangeDetailError(r)
+    );
+    const editCompletes = this._getEdit();
+    const prefCompletes = this._getPreferences();
+
+    return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
+      ([change, edit, prefs]) => {
+        this._prefs = prefs;
+
+        if (!change) {
+          return false;
+        }
+        this._processEdit(change, edit);
+        // Issue 4190: Coalesce missing topics to null.
+        // TODO(TS): code needs second thought,
+        // it might be that nulls were assigned to trigger some bindings
+        if (!change.topic) {
+          change.topic = (null as unknown) as undefined;
+        }
+        if (!change.reviewer_updates) {
+          change.reviewer_updates = (null as unknown) as undefined;
+        }
+        const latestRevisionSha = this._getLatestRevisionSHA(change);
+        if (!latestRevisionSha)
+          throw new Error('Could not find latest Revision Sha');
+        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 = Number(lineHeight.slice(0, lineHeight.length - 2));
+
+        this._change = change;
+        if (
+          !this._patchRange ||
+          !this._patchRange.patchNum ||
+          patchNumEquals(this._patchRange.patchNum, currentRevision._number)
+        ) {
+          // CommitInfo.commit is optional, and may need patching.
+          if (currentRevision.commit && !currentRevision.commit.commit) {
+            currentRevision.commit.commit = latestRevisionSha as CommitId;
+          }
+          this._commitInfo = currentRevision.commit;
+          this._selectedRevision = currentRevision;
+          // TODO: Fetch and process files.
+        } else {
+          if (!this._change?.revisions || !this._patchRange) return false;
+          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 === Number(`${thePatchNum}`);
+            }
+          );
+        }
+        return false;
+      }
+    );
+  }
+
+  _isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
+    return !!(
+      revisionActions &&
+      revisionActions.submit &&
+      revisionActions.submit.enabled
+    );
+  }
+
+  _isParentCurrent(revisionActions: ActionNameToActionInfoMap) {
+    if (revisionActions && revisionActions.rebase) {
+      return !revisionActions.rebase.enabled;
+    } else {
+      return true;
+    }
+  }
+
+  _getEdit() {
+    if (!this._changeNum)
+      return Promise.reject(new Error('missing required changeNum property'));
+    return this.$.restAPI.getChangeEdit(this._changeNum, true);
+  }
+
+  _getLatestCommitMessage() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (lastpatchNum === undefined)
+      throw new Error('missing lastPatchNum property');
+    return this.$.restAPI
+      .getChangeCommitInfo(this._changeNum, lastpatchNum)
+      .then(commitInfo => {
+        if (!commitInfo) return;
+        this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+          commitInfo.message
+        );
+      });
+  }
+
+  _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
+    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 as PatchSetNum;
+    for (const rev in change.revisions) {
+      if (!hasOwnProperty(change.revisions, rev)) {
+        continue;
+      }
+
+      if (change.revisions[rev]._number > latestPatchNum) {
+        latestRev = rev;
+        latestPatchNum = change.revisions[rev]._number;
+      }
+    }
+    return latestRev;
+  }
+
+  _getCommitInfo() {
+    if (!this._changeNum)
+      throw new Error('missing required _changeNum property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (this._patchRange.patchNum === undefined)
+      throw new Error('missing required patchNum property');
+
+    // We only call _getEdit if the patchset number is an edit.
+    // We have to do this to ensure we can tell if an edit
+    // exists or not.
+    // This safely works even if a edit does not exist.
+    if (this._patchRange!.patchNum! === EditPatchSetNum) {
+      return this._getEdit().then(edit => {
+        if (!edit) {
+          return Promise.resolve();
+        }
+
+        return this._getChangeCommitInfo();
+      });
+    }
+
+    return this._getChangeCommitInfo();
+  }
+
+  _getChangeCommitInfo() {
+    return this.$.restAPI
+      .getChangeCommitInfo(this._changeNum!, this._patchRange!.patchNum!)
+      .then(commitInfo => {
+        this._commitInfo = commitInfo;
+      });
+  }
+
+  _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
+    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() {
+    // We are resetting all comment related properties, because we want to avoid
+    // a new change being loaded and then paired with outdated comments.
+    this._changeComments = undefined;
+    this._commentThreads = undefined;
+    this._diffDrafts = undefined;
+    this._draftCommentThreads = undefined;
+    this._robotCommentThreads = undefined;
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    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() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    return this.$.commentAPI
+      .reloadDrafts(this._changeNum)
+      .then(comments => this._recomputeComments(comments));
+  }
+
+  _recomputeComments(comments: ChangeComments) {
+    this._changeComments = comments;
+    this._diffDrafts = {...this._changeComments.drafts};
+    this._commentThreads = this._changeComments.getAllThreadsForChange();
+    this._draftCommentThreads = this._commentThreads
+      .filter(isDraftThread)
+      .map(thread => {
+        const copiedThread = {...thread};
+        // Make a hardcopy of all comments and collapse all but last one
+        const commentsInThread = (copiedThread.comments = thread.comments.map(
+          comment => {
+            return {...comment, collapsed: true as boolean};
+          }
+        ));
+        commentsInThread[commentsInThread.length - 1].collapsed = false;
+        return copiedThread;
+      });
+  }
+
+  /**
+   * Reload the change.
+   *
+   * @param isLocationChange Reloads the related changes
+   * when true and ends reporting events that started on location change.
+   * @param clearPatchset Reloads the related changes
+   * ignoring any patchset choice made.
+   * @return 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(isLocationChange?: boolean, clearPatchset?: boolean) {
+    if (clearPatchset && this._change) {
+      GerritNav.navigateToChange(this._change);
+      return Promise.resolve([]);
+    }
+    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: Promise<unknown>[] = [];
+
+    // 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 (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 = Promise.all([mergeabilityLoaded]);
+    }
+
+    if (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 (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 === ChangeStatus.MERGED ||
+      this._change.status === ChangeStatus.ABANDONED
+    ) {
+      this._mergeable = false;
+      return Promise.resolve();
+    }
+
+    if (!this._changeNum) {
+      return Promise.reject(new Error('missing required changeNum property'));
+    }
+
+    // If mergeable bit was already returned in detail REST endpoint, use it.
+    if (this._change.mergeable !== undefined) {
+      this._mergeable = this._change.mergeable;
+      return Promise.resolve();
+    }
+
+    this._mergeable = null;
+    return this.$.restAPI.getMergeable(this._changeNum).then(mergableInfo => {
+      if (mergableInfo) {
+        this._mergeable = mergableInfo.mergeable;
+      }
+    });
+  }
+
+  _computeCanStartReview(change: ChangeInfo) {
+    return !!(
+      change.actions &&
+      change.actions.ready &&
+      change.actions.ready.enabled
+    );
+  }
+
+  _computeReplyDisabled() {
+    return false;
+  }
+
+  _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
+    return `Change ${changeNum}`;
+  }
+
+  _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+    return collapsible && collapsed;
+  }
+
+  _computeRelatedChangesClass(collapsed: boolean) {
+    return collapsed ? 'collapsed' : '';
+  }
+
+  _computeCollapseText(collapsed: boolean) {
+    // 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
+   */
+  _computeCopyTextForTitle(change: ChangeInfo) {
+    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?: string) {
+    if (!commitMessage) {
+      return false;
+    }
+    return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+  }
+
+  _getOffsetHeight(element: HTMLElement) {
+    return element.offsetHeight;
+  }
+
+  _getScrollHeight(element: HTMLElement) {
+    return element.scrollHeight;
+  }
+
+  /**
+   * Get the line height of an element to the nearest integer.
+   */
+  _getLineHeight(element: Element) {
+    const lineHeightStr = getComputedStyle(element).lineHeight;
+    return Math.round(Number(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: {[key: string]: string} = {};
+
+    // 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 (!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);
+    }
+    return (this._showRelatedToggle = false);
+  }
+
+  _updateToggleContainerClass(showRelatedToggle: boolean) {
+    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(() => {
+      if (!this._change) throw new Error('missing required change property');
+      const change = this._change;
+      fetchChangeUpdates(change, this.$.restAPI).then(result => {
+        let toastMessage = null;
+        if (!result.isLatest) {
+          toastMessage = ReloadToastMessage.NEWER_REVISION;
+        } else if (result.newStatus === ChangeStatus.MERGED) {
+          toastMessage = ReloadToastMessage.MERGED;
+        } else if (result.newStatus === ChangeStatus.ABANDONED) {
+          toastMessage = ReloadToastMessage.ABANDONED;
+        } else if (result.newStatus === ChangeStatus.NEW) {
+          toastMessage = ReloadToastMessage.RESTORED;
+        } else if (result.newMessages) {
+          toastMessage = ReloadToastMessage.NEW_MESSAGE;
+        }
+
+        // We have to make sure that the update is still relevant for the user.
+        // Since starting to fetch the change update the user may have sent a
+        // reply, or the change might have been reloaded, or it could be in the
+        // process of being reloaded.
+        const changeWasReloaded = change !== this._change;
+        if (!toastMessage || this._loading || changeWasReloaded) {
+          this._startUpdateCheckTimer();
+          return;
+        }
+
+        this._cancelUpdateCheckTimer();
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {
+              message: toastMessage,
+              // Persist this alert.
+              dismissOnNavigation: true,
+              action: 'Reload',
+              callback: () => {
+                this._reload(
+                  /* isLocationChange= */ false,
+                  /* clearPatchset= */ true
+                );
+              },
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
+    }, this._serverConfig.change.update_delay * 1000);
+  }
+
+  _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();
+    }
+  }
+
+  _handleTopicChanged() {
+    this.$.relatedChanges.reload();
+  }
+
+  _computeHeaderClass(editMode?: boolean) {
+    const classes = ['header'];
+    if (editMode) {
+      classes.push('editMode');
+    }
+    return classes.join(' ');
+  }
+
+  _computeEditMode(
+    patchRangeRecord: PolymerDeepPropertyChange<
+      ChangeViewPatchRange,
+      ChangeViewPatchRange
+    >,
+    paramsRecord: PolymerDeepPropertyChange<
+      AppElementChangeViewParams,
+      AppElementChangeViewParams
+    >
+  ) {
+    if (!patchRangeRecord || !paramsRecord) {
+      return undefined;
+    }
+
+    if (paramsRecord.base && paramsRecord.base.edit) {
+      return true;
+    }
+
+    const patchRange = patchRangeRecord.base || {};
+    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+  }
+
+  _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+    e.preventDefault();
+    const controls = this.$.fileListHeader.shadowRoot!.querySelector(
+      '#editControls'
+    ) as GrEditControls | null;
+    if (!controls) throw new Error('Missing edit controls');
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    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: NumericChangeId, revision: CommitId) {
+    return `c${number}_rev${revision}`;
+  }
+
+  @observe('_patchRange.patchNum')
+  _patchNumChanged(patchNumStr: PatchSetNum) {
+    if (!this._selectedRevision) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+
+    let patchNum: PatchSetNum;
+    if (patchNumStr === 'edit') {
+      patchNum = EditPatchSetNum;
+    } else {
+      patchNum = Number(`${patchNumStr}`) as PatchSetNum;
+    }
+
+    if (patchNum === this._selectedRevision._number) {
+      return;
+    }
+    if (this._change.revisions)
+      this._selectedRevision = Object.values(this._change.revisions).find(
+        revision => revision._number === patchNum
+      );
+  }
+
+  /**
+   * If an edit exists already, load it. Otherwise, toggle edit mode via the
+   * navigation API.
+   */
+  _handleEditTap() {
+    if (!this._change || !this._change.revisions)
+      throw new Error('missing required change property');
+    const editInfo = Object.values(this._change.revisions).find(
+      info => info._number === EditPatchSetNum
+    );
+
+    if (editInfo) {
+      GerritNav.navigateToChange(this._change, EditPatchSetNum);
+      return;
+    }
+
+    // Avoid putting patch set in the URL unless a non-latest patch set is
+    // selected.
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    let patchNum;
+    if (
+      !patchNumEquals(
+        this._patchRange.patchNum,
+        computeLatestPatchNum(this._allPatchSets)
+      )
+    ) {
+      patchNum = this._patchRange.patchNum;
+    }
+    GerritNav.navigateToChange(this._change, patchNum, undefined, true);
+  }
+
+  _handleStopEditTap() {
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+  }
+
+  _resetReplyOverlayFocusStops() {
+    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+  }
+
+  _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+  }
+
+  _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
+    return new RevisionInfoClass(change);
+  }
+
+  _computeCurrentRevision(
+    currentRevision: CommitId,
+    revisions: {[revisionId: string]: RevisionInfo}
+  ) {
+    return currentRevision && revisions && revisions[currentRevision];
+  }
+
+  _computeDiffPrefsDisabled(disableDiffPrefs: boolean, loggedIn: boolean) {
+    return disableDiffPrefs || !loggedIn;
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeLatestPatchNum(allPatchSets: PatchSet[]) {
+    return computeLatestPatchNum(allPatchSets);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+    return hasEditBasedOnCurrentPatchSet(allPatchSets);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _hasEditPatchsetLoaded(
+    patchRangeRecord: PolymerDeepPropertyChange<
+      ChangeViewPatchRange,
+      ChangeViewPatchRange
+    >
+  ) {
+    const patchRange = patchRangeRecord.base;
+    if (!patchRange) {
+      return false;
+    }
+    return hasEditPatchsetLoaded(patchRange);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeAllPatchSets(change: ChangeInfo) {
+    return computeAllPatchSets(change);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-view': 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
deleted file mode 100644
index a1d4c52..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
+++ /dev/null
@@ -1,793 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
new file mode 100644
index 0000000..efc86bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -0,0 +1,787 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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);
+      padding-right: var(--spacing-m);
+    }
+    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;
+      position: relative; /* for arrowToCurrentChange to have position:absolute and be 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 {
+      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)]]">
+        <h1 class="assistive-tech-only">
+          Change [[_change._number]]: [[_change.subject]]
+        </h1>
+        <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-edit-tap="_handleEditTap"
+            on-stop-edit-tap="_handleStopEditTap"
+            on-download-tap="_handleOpenDownloadDialog"
+          ></gr-change-actions>
+        </div>
+        <!-- end commit actions -->
+      </div>
+      <!-- end header -->
+      <h2 class="assistive-tech-only">Change metadata</h2>
+      <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">
+              <h3 class="assistive-tech-only">Commit Message</h3>
+              <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"
+                  title="Edit commit message"
+                  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>
+
+    <h2 class="assistive-tech-only">Files and Comments tabs</h2>
+    <paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
+      <paper-tab data-name$="[[_constants.PrimaryTab.FILES]]">Files</paper-tab>
+      <paper-tab
+        data-name$="[[_constants.PrimaryTab.COMMENT_THREADS]]"
+        class="commentThreads"
+      >
+        <gr-tooltip-content
+          has-tooltip=""
+          title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]"
+        >
+          <span>Comments</span></gr-tooltip-content
+        >
+      </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.PrimaryTab.FINDINGS]]">
+        Findings
+      </paper-tab>
+    </paper-tabs>
+
+    <section class="patchInfo">
+      <div
+        hidden$="[[!_isTabActive(_constants.PrimaryTab.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.PrimaryTab.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"
+          unresolved-only
+        ></gr-thread-list>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_constants.PrimaryTab.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.SecondaryTab.CHANGE_LOG]]"
+        class="changeLog"
+      >
+        Change Log
+      </paper-tab>
+    </paper-tabs>
+    <section class="changeLog">
+      <h2 class="assistive-tech-only">Change Log</h2>
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_constants.SecondaryTab.CHANGE_LOG, _activeTabs)]]"
+      >
+        <gr-messages-list
+          class="hideOnMobileOverlay"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          labels="[[_change.labels]]"
+          messages="[[_change.messages]]"
+          reviewer-updates="[[_change.reviewer_updates]]"
+          change-comments="[[_changeComments]]"
+          project-name="[[_change.project]]"
+          show-reply-buttons="[[_loggedIn]]"
+          on-message-anchor-tap="_handleMessageAnchorTap"
+          on-reply="_handleMessageReply"
+        ></gr-messages-list>
+      </template>
+    </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=""
+    scroll-action="lock"
+    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]]"
+      server-config="[[_serverConfig]]"
+      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>
+`;
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
deleted file mode 100644
index 913a914..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ /dev/null
@@ -1,2354 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-change-view</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/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-view></gr-change-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<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';
-
-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';
-
-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());
-    });
-
-    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(() => {
-        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
-        done();
-      });
-    });
-
-    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',
-            },
-          }));
-      flush(() => {
-        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
-        done();
-      });
-    });
-
-    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(
-          {
-            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',
-          },
-        },
-      };
-      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('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('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'));
-    });
-  });
-
-  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('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',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        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: [],
-            default_value: 0,
-            values: [],
-            approved: {},
-          },
-        },
-      };
-      sandbox.stub(element.$.relatedChanges, 'reload');
-      sandbox.stub(element, '_reload').returns(Promise.resolve());
-      sandbox.spy(element, '_paramsChanged');
-      element.params = {view: 'change', changeNum: '1'};
-    });
-
-    test('tab switch works correctly', done => {
-      assert.isTrue(element._paramsChanged.called);
-      assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
-
-      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._activeTabs[1], SecondaryTabs.CHANGE_LOG);
-        done();
-      });
-    });
-
-    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('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._activeTabs[1], SecondaryTabs.COMMENT_THREADS);
-        done();
-      });
-    });
-
-    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, commit: {parents: []}},
-          rev1: {_number: 1, commit: {parents: []}},
-          rev13: {_number: 13, commit: {parents: []}},
-          rev3: {_number: 3, commit: {parents: []}},
-          rev4: {_number: 4, commit: {parents: []}},
-        },
-        current_revision: 'rev4',
-      };
-      element._commentThreads = THREADS;
-      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[2]);
-      flush(() => {
-        done();
-      });
-    });
-
-    test('robot comments count per patchset', () => {
-      const count = element._robotCommentCountPerPatchSet(THREADS);
-      const expectedCount = {
-        2: 1,
-        3: 1,
-        4: 2,
-      };
-      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');
-    });
-
-    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');
-    });
-
-    test('changing patchsets resets robot comments', done => {
-      element.set('_change.current_revision', 'rev3');
-      flush(() => {
-        assert.equal(element._robotCommentThreads.length, 1);
-        done();
-      });
-    });
-
-    test('Show more button is hidden', () => {
-      assert.isNull(element.shadowRoot.querySelector('.show-robot-comments'));
-    });
-
-    suite('robot comments show more button', () => {
-      setup(done => {
-        const arr = [];
-        for (let i = 0; i <= 30; i++) {
-          arr.push(...THREADS);
-        }
-        element._commentThreads = arr;
-        flush(() => {
-          done();
-        });
-      });
-
-      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.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-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
new file mode 100644
index 0000000..8d06a03
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -0,0 +1,2947 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import '../../edit/gr-edit-constants';
+import './gr-change-view';
+import {
+  ChangeStatus,
+  CommentSide,
+  DefaultBase,
+  DiffViewMode,
+  HttpMethod,
+  PrimaryTab,
+  SecondaryTab,
+} from '../../../constants/constants';
+import {GrEditConstants} from '../../edit/gr-edit-constants';
+import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
+import {EventType, PluginApi} from '../../plugins/gr-plugin-types';
+
+import 'lodash/lodash';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+  createAppElementChangeViewParams,
+  createApproval,
+  createChange,
+  createChangeConfig,
+  createChangeMessages,
+  createCommit,
+  createMergeable,
+  createPreferences,
+  createRevision,
+  createRevisions,
+  createServerInfo,
+  createUserConfig,
+  TEST_NUMERIC_CHANGE_ID,
+  TEST_PROJECT_NAME,
+  getCurrentRevision,
+  createEditRevision,
+  createAccountWithIdNameAndEmail,
+} from '../../../test/test-data-generators';
+import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
+import {
+  AccountId,
+  ApprovalInfo,
+  ChangeId,
+  ChangeInfo,
+  CommitId,
+  CommitInfo,
+  EditInfo,
+  EditPatchSetNum,
+  ElementPropertyDeepChange,
+  GitRef,
+  NumericChangeId,
+  ParentPatchSetNum,
+  ParsedJSON,
+  PatchRange,
+  PatchSetNum,
+  RevisionInfo,
+  RobotId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {
+  pressAndReleaseKeyOn,
+  tap,
+} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {AppElementChangeViewParams} from '../../gr-app-types';
+import {
+  SinonFakeTimers,
+  SinonSpy,
+  SinonStubbedMember,
+} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {CustomKeyboardEvent} from '../../../types/events';
+import {
+  CommentThread,
+  DraftInfo,
+  UIDraft,
+  UIRobot,
+} from '../../../utils/comment-util';
+import 'lodash/lodash';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+const fixture = fixtureFromElement('gr-change-view');
+
+type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
+  Parameters<F>,
+  ReturnType<F>
+>;
+
+suite('gr-change-view tests', () => {
+  let element: GrChangeView;
+
+  let navigateToChangeStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+
+  suiteSetup(() => {
+    const kb = TestKeyboardShortcutBinder.push();
+    kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
+    kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
+    kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
+    kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+    kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
+    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
+    kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
+    kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
+    kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+    kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
+    kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
+  });
+
+  suiteTeardown(() => {
+    TestKeyboardShortcutBinder.pop();
+  });
+
+  const TEST_SCROLL_TOP_PX = 100;
+
+  const ROBOT_COMMENTS_LIMIT = 10;
+
+  // TODO: should have a mock service to generate VALID fake data
+  const THREADS: CommentThread[] = [
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2 as PatchSetNum,
+          robot_id: 'rb1' as RobotId,
+          id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4 as PatchSetNum,
+          id: 'ecf0b9fa_fe1a5f62_1' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+          path: '/COMMIT_MSG',
+          line: 5,
+          in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+          updated: '2018-02-13 22:48:48.018000000' as Timestamp,
+          message: 'draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: 2 as PatchSetNum,
+        },
+      ],
+      patchNum: 4 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3 as PatchSetNum,
+          id: 'ecf0b9fa_fe5f62' as UrlEncodedCommentId,
+          robot_id: 'rb2' as RobotId,
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: 'test.txt',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3 as PatchSetNum,
+          id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+          side: CommentSide.PARENT,
+          updated: '2018-02-13 22:47:19.000000000' as Timestamp,
+          message: 'Some comment on another patchset.',
+          unresolved: false,
+        },
+      ],
+      patchNum: 3 as PatchSetNum,
+      path: 'test.txt',
+      rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+      commentSide: CommentSide.PARENT,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2 as PatchSetNum,
+          id: '8caddf38_44770ec1' as UrlEncodedCommentId,
+          line: 4,
+          updated: '2018-02-13 22:48:40.000000000' as Timestamp,
+          message: 'Another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2 as PatchSetNum,
+          id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+          line: 4,
+          updated: '2018-02-14 22:48:40.000000000' as Timestamp,
+          message: 'Yet another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+    },
+    {
+      comments: [
+        {
+          id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+          path: '/COMMIT_MSG',
+          line: 6,
+          updated: '2018-02-15 22:48:48.018000000' as Timestamp,
+          message: 'resolved draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: 2 as PatchSetNum,
+        },
+      ],
+      patchNum: 4 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 6,
+      rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4 as PatchSetNum,
+          id: 'rc1' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2019-02-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc1' as RobotId,
+        },
+      ],
+      patchNum: 4 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc1' as UrlEncodedCommentId,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4 as PatchSetNum,
+          id: 'rc2' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc2' as RobotId,
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4 as PatchSetNum,
+          id: 'c2_1' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+      patchNum: 4 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc2' as UrlEncodedCommentId,
+    },
+  ];
+
+  setup(() => {
+    // Since pluginEndpoints are global, must reset state.
+    _testOnly_resetEndpoints();
+    navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+
+    function getCommentsStub() {
+      return Promise.resolve({});
+    }
+    stub('gr-rest-api-interface', {
+      getConfig() {
+        return Promise.resolve({
+          ...createServerInfo(),
+          user: {
+            ...createUserConfig(),
+            anonymous_coward_name: 'test coward name',
+          },
+        });
+      },
+      getAccount() {
+        return Promise.resolve(undefined);
+      },
+      getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
+      getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
+      getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
+      _fetchSharedCacheURL() {
+        return Promise.resolve({} as ParsedJSON);
+      },
+    });
+    element = fixture.instantiate();
+    element._changeNum = 1 as NumericChangeId;
+    sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
+    getPluginLoader().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(() => {
+      done();
+    });
+  });
+
+  const getCustomCssValue = (cssParam: string) =>
+    getComputedStyleValue(cssParam, element);
+
+  test('_handleMessageAnchorTap', () => {
+    element._changeNum = 1 as NumericChangeId;
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 1 as PatchSetNum,
+    };
+    element._change = createChange();
+    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
+    const replaceStateStub = sinon.stub(history, 'replaceState');
+    element._handleMessageAnchorTap(
+      new CustomEvent('message-anchor-tap', {detail: {id: 'a12345'}})
+    );
+
+    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+    assert.isTrue(replaceStateStub.called);
+  });
+
+  test('_handleDiffAgainstBase', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      patchNum: 3 as PatchSetNum,
+      basePatchNum: 1 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffAgainstBase(new CustomEvent('') as CustomKeyboardEvent);
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[0], element._change);
+    assert.equal(args[1], 3 as PatchSetNum);
+  });
+
+  test('_handleDiffAgainstLatest', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      basePatchNum: 1 as PatchSetNum,
+      patchNum: 3 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffAgainstLatest(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[0], element._change);
+    assert.equal(args[1], 10 as PatchSetNum);
+    assert.equal(args[2], 1 as PatchSetNum);
+  });
+
+  test('_handleDiffBaseAgainstLeft', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      patchNum: 3 as PatchSetNum,
+      basePatchNum: 1 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffBaseAgainstLeft(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[0], element._change);
+    assert.equal(args[1], 1 as PatchSetNum);
+  });
+
+  test('_handleDiffRightAgainstLatest', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      basePatchNum: 1 as PatchSetNum,
+      patchNum: 3 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffRightAgainstLatest(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[1], 10 as PatchSetNum);
+    assert.equal(args[2], 3 as PatchSetNum);
+  });
+
+  test('_handleDiffBaseAgainstLatest', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      basePatchNum: 1 as PatchSetNum,
+      patchNum: 3 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffBaseAgainstLatest(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[1], 10 as PatchSetNum);
+    assert.isNotOk(args[2]);
+  });
+
+  suite('plugins adding to file tab', () => {
+    setup(done => {
+      element._changeNum = 1 as NumericChangeId;
+      // Resolving it here instead of during setup() as other tests depend
+      // on flush() not being called during setup.
+      flush(() => done());
+    });
+
+    test('plugin added tab shows up as a dynamic endpoint', () => {
+      assert(
+        element._dynamicTabHeaderEndpoints.includes(
+          'change-view-tab-header-url'
+        )
+      );
+      const primaryTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+      const paperTabs = primaryTabs.querySelectorAll<HTMLElement>('paper-tab');
+      // 4 Tabs are : Files, Comment Threads, Plugin, Findings
+      assert.equal(primaryTabs.querySelectorAll('paper-tab').length, 4);
+      assert.equal(paperTabs[2].dataset.name, 'change-view-tab-header-url');
+    });
+
+    test('_setActivePrimaryTab switched tab correctly', done => {
+      element._setActivePrimaryTab(
+        new CustomEvent('', {
+          detail: {tab: 'change-view-tab-header-url'},
+        })
+      );
+      flush(() => {
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+        done();
+      });
+    });
+
+    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',
+          },
+        })
+      );
+      flush(() => {
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+        done();
+      });
+    });
+
+    test('param change should switch primary tab correctly', done => {
+      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      const queryMap = new Map<string, string>();
+      queryMap.set('tab', PrimaryTab.FINDINGS);
+      // view is required
+      element.params = {
+        ...createAppElementChangeViewParams(),
+        ...element.params,
+        queryMap,
+      };
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
+        done();
+      });
+    });
+
+    test('invalid param change should not switch primary tab', done => {
+      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      const queryMap = new Map<string, string>();
+      queryMap.set('tab', 'random');
+      // view is required
+      element.params = {
+        ...createAppElementChangeViewParams(),
+        ...element.params,
+        queryMap,
+      };
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+        done();
+      });
+    });
+
+    test('switching tab sets _selectedTabPluginEndpoint', done => {
+      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+      tap(paperTabs.querySelectorAll('paper-tab')[2]);
+      flush(() => {
+        assert.equal(
+          element._selectedTabPluginEndpoint,
+          'change-view-tab-content-url'
+        );
+        done();
+      });
+    });
+  });
+
+  suite('keyboard shortcuts', () => {
+    let clock: SinonFakeTimers;
+    setup(() => {
+      clock = sinon.useFakeTimers();
+    });
+
+    teardown(() => {
+      clock.restore();
+      sinon.restore();
+    });
+
+    test('t to add topic', () => {
+      const editStub = sinon.stub(element.$.metadata, 'editTopic');
+      pressAndReleaseKeyOn(element, 83, null, 't');
+      assert(editStub.called);
+    });
+
+    test('S should toggle the CL star', () => {
+      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
+      pressAndReleaseKeyOn(element, 83, null, 's');
+      assert(starStub.called);
+    });
+
+    test('toggle star is throttled', () => {
+      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
+      pressAndReleaseKeyOn(element, 83, null, 's');
+      assert(starStub.called);
+      pressAndReleaseKeyOn(element, 83, null, 's');
+      assert.equal(starStub.callCount, 1);
+      clock.tick(1000);
+      pressAndReleaseKeyOn(element, 83, null, 's');
+      assert.equal(starStub.callCount, 2);
+    });
+
+    test('U should navigate to root if no backPage set', () => {
+      const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      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 = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      element.backPage = '/dashboard/self';
+      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 => {
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      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 => {
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        done();
+      });
+    });
+
+    test('A toggles overlay when logged in', done => {
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(1),
+        messages: createChangeMessages(1),
+      };
+      element._change.labels = {};
+      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        Promise.resolve({
+          ...createChange(),
+          // element has latest info
+          revisions: createRevisions(1),
+          messages: createChangeMessages(1),
+          current_revision: 'rev1' as CommitId,
+        })
+      );
+
+      const openSpy = sinon.spy(element, '_openReplyDialog');
+
+      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 = {
+        ...createChange(),
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: HttpMethod.POST,
+            title: 'Abandon',
+          },
+        },
+      };
+      const handlerSpy = sinon.spy(element, '_handleHideBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-opened', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handlerSpy.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 = {
+        ...createChange(),
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: HttpMethod.POST,
+            title: 'Abandon',
+          },
+        },
+      };
+      const handlerSpy = sinon.spy(element, '_handleShowBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-closed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handlerSpy.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('expand all messages when expand-diffs fired', () => {
+      const handleExpand = sinon.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 = sinon.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 = sinon.stub(
+          element.messagesList!,
+          'handleExpandCollapse'
+        );
+        pressAndReleaseKeyOn(element, 88, null, 'x');
+        assert(handleExpand.calledWith(true));
+        done();
+      });
+    });
+
+    test('Z should collapse all messages', done => {
+      flush(() => {
+        const handleExpand = sinon.stub(
+          element.messagesList!,
+          'handleExpandCollapse'
+        );
+        pressAndReleaseKeyOn(element, 90, null, 'z');
+        assert(handleExpand.calledWith(false));
+        done();
+      });
+    });
+
+    test('reload event from reply dialog is processed', () => {
+      const handleReloadStub = sinon.stub(element, '_reload');
+      element.$.replyDialog.dispatchEvent(
+        new CustomEvent('reload', {
+          detail: {clearPatchset: true},
+          bubbles: true,
+          composed: true,
+        })
+      );
+      assert.isTrue(handleReloadStub.called);
+    });
+
+    test('shift + R should fetch and navigate to the latest patch set', done => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
+      element._patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as PatchSetNum,
+      };
+      element._change = {
+        ...createChange(),
+        revisions: {
+          rev1: createRevision(),
+        },
+        current_revision: 'rev1' as CommitId,
+        status: ChangeStatus.NEW,
+        labels: {},
+        actions: {},
+      };
+
+      const reloadChangeStub = sinon.stub(element, '_reload');
+      pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      flush(() => {
+        assert.isTrue(reloadChangeStub.called);
+        done();
+      });
+    });
+
+    test('d should open download overlay', () => {
+      const stub = sinon
+        .stub(element.$.downloadOverlay, 'open')
+        .returns(Promise.resolve());
+      pressAndReleaseKeyOn(element, 68, null, 'd');
+      assert.isTrue(stub.called);
+    });
+
+    test(', should open diff preferences', () => {
+      const stub = sinon.stub(
+        element.$.fileList.$.diffPreferencesDialog,
+        'open'
+      );
+      element._loggedIn = false;
+      element.disableDiffPrefs = true;
+      pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element._loggedIn = true;
+      pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element.disableDiffPrefs = false;
+      pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isTrue(stub.called);
+    });
+
+    test('m should toggle diff mode', () => {
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const setModeStub = sinon.stub(
+        element.$.fileListHeader,
+        'setDiffViewMode'
+      );
+      const e = {preventDefault: () => {}} as CustomKeyboardEvent;
+      flush();
+
+      element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith(DiffViewMode.UNIFIED));
+
+      element.viewState.diffMode = DiffViewMode.UNIFIED;
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith(DiffViewMode.SIDE_BY_SIDE));
+    });
+  });
+
+  suite('reloading drafts', () => {
+    let reloadStub: SinonStubbedMember<typeof element.$.commentAPI.reloadDrafts>;
+    const drafts: {[path: string]: UIDraft[]} = {
+      'testfile.txt': [
+        {
+          patch_set: 5 as PatchSetNum,
+          id: 'dd2982f5_c01c9e6a' as UrlEncodedCommentId,
+          line: 1,
+          updated: '2017-11-08 18:47:45.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+    };
+    setup(() => {
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
+        Promise.resolve({
+          drafts,
+          getAllThreadsForChange: () => [] as CommentThread[],
+          computeDraftCount: () => 1,
+        } as ChangeComments)
+      );
+      element._changeNum = 1 as NumericChangeId;
+    });
+
+    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);
+    });
+  });
+
+  suite('_recomputeComments', () => {
+    setup(() => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
+      element._change = createChange();
+      flush();
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
+        Promise.resolve({
+          drafts: {},
+          getAllThreadsForChange: () => THREADS,
+          computeDraftCount: () => 0,
+        } as ChangeComments)
+      );
+      element._change = createChange();
+      element._changeNum = element._change._number;
+    });
+
+    test('draft threads should be a new copy with correct states', done => {
+      element.$.fileList.dispatchEvent(
+        new CustomEvent('reload-drafts', {
+          detail: {
+            resolve: () => {
+              assert.equal(element._draftCommentThreads!.length, 2);
+              assert.equal(
+                element._draftCommentThreads![0].rootId,
+                THREADS[0].rootId
+              );
+              assert.notEqual(
+                element._draftCommentThreads![0].comments,
+                THREADS[0].comments
+              );
+              assert.notEqual(
+                element._draftCommentThreads![0].comments[0],
+                THREADS[0].comments[0]
+              );
+              assert.isTrue(
+                element
+                  ._draftCommentThreads![0].comments.slice(0, 2)
+                  .every(c => c.collapsed === true)
+              );
+
+              assert.isTrue(
+                element._draftCommentThreads![0].comments[2].collapsed === false
+              );
+              done();
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+  });
+
+  test('diff comments modified', () => {
+    const reloadThreadsSpy = sinon.spy(element, '_handleReloadCommentThreads');
+    return element._reloadComments().then(() => {
+      element.dispatchEvent(
+        new CustomEvent('diff-comments-modified', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(reloadThreadsSpy.called);
+    });
+  });
+
+  test('thread list modified', () => {
+    const reloadDiffSpy = sinon.spy(element, '_handleReloadDiffComments');
+    element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
+    flush();
+
+    return element._reloadComments().then(() => {
+      element.threadList!.dispatchEvent(
+        new CustomEvent('thread-list-modified', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(reloadDiffSpy.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 = TEST_NUMERIC_CHANGE_ID;
+      element._patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as PatchSetNum,
+      };
+      element._change = {
+        ...createChange(),
+        revisions: {
+          rev2: createRevision(2),
+          rev1: createRevision(1),
+          rev13: createRevision(13),
+          rev3: createRevision(3),
+        },
+        current_revision: 'rev3' as CommitId,
+        status: ChangeStatus.NEW,
+        labels: {
+          test: {
+            all: [],
+            default_value: 0,
+            values: {},
+            approved: {},
+          },
+        },
+      };
+      sinon.stub(element.$.relatedChanges, 'reload');
+      sinon.stub(element, '_reload').returns(Promise.resolve([]));
+      sinon.spy(element, '_paramsChanged');
+      element.params = createAppElementChangeViewParams();
+    });
+  });
+
+  suite('Findings comment tab', () => {
+    setup(done => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
+      element._change = {
+        ...createChange(),
+        revisions: {
+          rev2: createRevision(2),
+          rev1: createRevision(1),
+          rev13: createRevision(13),
+          rev3: createRevision(3),
+          rev4: createRevision(4),
+        },
+        current_revision: 'rev4' as CommitId,
+      };
+      element._commentThreads = THREADS;
+      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+      tap(paperTabs.querySelectorAll('paper-tab')[3]);
+      flush(() => {
+        done();
+      });
+    });
+
+    test('robot comments count per patchset', () => {
+      const count = element._robotCommentCountPerPatchSet(THREADS);
+      const expectedCount = {
+        2: 1,
+        3: 1,
+        4: 2,
+      };
+      assert.deepEqual(count, expectedCount);
+      assert.equal(
+        element._computeText(createRevision(2), THREADS),
+        'Patchset 2 (1 finding)'
+      );
+      assert.equal(
+        element._computeText(createRevision(4), THREADS),
+        'Patchset 4 (2 findings)'
+      );
+      assert.equal(
+        element._computeText(createRevision(5), THREADS),
+        'Patchset 5'
+      );
+    });
+
+    test('only robot comments are rendered', () => {
+      assert.equal(element._robotCommentThreads!.length, 2);
+      assert.equal(
+        (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
+        'rc1'
+      );
+      assert.equal(
+        (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
+        'rc2'
+      );
+    });
+
+    test('changing patchsets resets robot comments', done => {
+      element.set('_change.current_revision', 'rev3');
+      flush(() => {
+        assert.equal(element._robotCommentThreads!.length, 1);
+        done();
+      });
+    });
+
+    test('Show more button is hidden', () => {
+      assert.isNull(element.shadowRoot!.querySelector('.show-robot-comments'));
+    });
+
+    suite('robot comments show more button', () => {
+      setup(done => {
+        const arr = [];
+        for (let i = 0; i <= 30; i++) {
+          arr.push(...THREADS);
+        }
+        element._commentThreads = arr;
+        flush(() => {
+          done();
+        });
+      });
+
+      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 => {
+        tap(element.shadowRoot!.querySelector('.show-robot-comments')!);
+        flush(() => {
+          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', () => {
+    const openDialogStub = sinon.stub(element, '_handleOpenDownloadDialog');
+    element.$.actions.dispatchEvent(
+      new CustomEvent('download-tap', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.isTrue(openDialogStub.called);
+  });
+
+  test('fetches the server config on attached', done => {
+    flush(() => {
+      assert.equal(
+        element._serverConfig!.user.anonymous_coward_name,
+        'test coward name'
+      );
+      done();
+    });
+  });
+
+  test('_changeStatuses', () => {
+    element._loading = false;
+    element._change = {
+      ...createChange(),
+      revisions: {
+        rev2: createRevision(2),
+        rev1: createRevision(1),
+        rev13: createRevision(13),
+        rev3: createRevision(3),
+      },
+      current_revision: 'rev3' as CommitId,
+      status: ChangeStatus.MERGED,
+      work_in_progress: true,
+      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(', '));
+    flush();
+    const statusChips = element.shadowRoot!.querySelectorAll(
+      'gr-change-status'
+    );
+    assert.equal(statusChips.length, 2);
+  });
+
+  test('diff preferences open when open-diff-prefs is fired', () => {
+    const overlayOpenStub = sinon.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: ApprovalInfo = {
+      ...createApproval(),
+      _account_id: 1 as AccountId,
+      name: 'bojack',
+      value: 1,
+    };
+    element._changeNum = TEST_NUMERIC_CHANGE_ID;
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 1 as PatchSetNum,
+    };
+    const change = {
+      ...createChange(),
+      owner: createAccountWithIdNameAndEmail(),
+      revisions: {
+        rev2: createRevision(2),
+        rev1: createRevision(1),
+        rev13: createRevision(13),
+        rev3: createRevision(3),
+      },
+      current_revision: 'rev3' as CommitId,
+      status: ChangeStatus.NEW,
+      labels: {
+        test: {
+          all: [vote],
+          default_value: 0,
+          values: {},
+          approved: {},
+        },
+      },
+    };
+    element._change = change;
+    flush();
+    const reloadStub = sinon.stub(element, '_reload');
+    element.splice('_change.labels.test.all', 0, 1);
+    assert.isFalse(reloadStub.called);
+    change.labels.test.all.push(vote);
+    change.labels.test.all.push(vote);
+    change.labels.test.approved = vote;
+    flush();
+    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: ElementPropertyDeepChange<
+      GrChangeView,
+      '_diffDrafts'
+    > = {base: undefined, path: '', value: undefined};
+    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)');
+    assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
+  });
+
+  test('comment events properly update diff drafts', () => {
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 2 as PatchSetNum,
+    };
+    const draft: DraftInfo = {
+      __draft: true,
+      id: 'id1' as UrlEncodedCommentId,
+      path: '/foo/bar.txt',
+      message: 'hello',
+    };
+    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
+    draft.patch_set = 2 as PatchSetNum;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    draft.patch_set = undefined;
+    draft.message = 'hello, there';
+    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
+    draft.patch_set = 2 as PatchSetNum;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    const draft2: DraftInfo = {
+      __draft: true,
+      id: 'id2' as UrlEncodedCommentId,
+      path: '/foo/bar.txt',
+      message: 'hola',
+    };
+    element._handleCommentSave(
+      new CustomEvent('', {detail: {comment: draft2}})
+    );
+    draft2.patch_set = 2 as PatchSetNum;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
+    draft.patch_set = undefined;
+    element._handleCommentDiscard(
+      new CustomEvent('', {detail: {comment: draft}})
+    );
+    draft.patch_set = 2 as PatchSetNum;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
+    element._handleCommentDiscard(
+      new CustomEvent('', {detail: {comment: draft2}})
+    );
+    assert.deepEqual(element._diffDrafts, {});
+  });
+
+  test('change num change', () => {
+    element._changeNum = undefined;
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 2 as PatchSetNum,
+    };
+    element._change = {
+      ...createChange(),
+      labels: {},
+    };
+    element.viewState.changeNum = null;
+    element.viewState.diffMode = DiffViewMode.UNIFIED;
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+    element._numFilesShown = 150;
+    flush();
+    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+    assert.equal(element.viewState.numFilesShown, 150);
+
+    element._changeNum = 1 as NumericChangeId;
+    element.params = {
+      ...createAppElementChangeViewParams(),
+      changeNum: 1 as NumericChangeId,
+    };
+    flush();
+    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+    assert.equal(element.viewState.changeNum, 1);
+
+    element._changeNum = 2 as NumericChangeId;
+    element.params = {
+      ...createAppElementChangeViewParams(),
+      changeNum: 2 as NumericChangeId,
+    };
+    flush();
+    assert.equal(element.viewState.diffMode, DiffViewMode.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', () => {
+    const setDiffViewModeStub = sinon.stub(element, '_setDiffViewMode');
+    element.viewState = {changeNum: 1 as NumericChangeId};
+    element._changeNum = 2 as NumericChangeId;
+    element._resetFileListViewState();
+    assert.isTrue(setDiffViewModeStub.calledWithExactly(true));
+  });
+
+  test('diffViewMode is propagated from file list header', () => {
+    element.viewState = {diffMode: DiffViewMode.UNIFIED};
+    element.$.fileListHeader.diffViewMode = DiffViewMode.SIDE_BY_SIDE;
+    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
+  });
+
+  test('diffMode defaults to side by side without preferences', done => {
+    sinon
+      .stub(element.$.restAPI, 'getPreferences')
+      .returns(Promise.resolve(createPreferences()));
+    // No user prefs or diff view mode set.
+
+    element._setDiffViewMode()!.then(() => {
+      assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
+      done();
+    });
+  });
+
+  test('diffMode defaults to preference when not already set', done => {
+    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+      Promise.resolve({
+        ...createPreferences(),
+        default_diff_view: DiffViewMode.UNIFIED,
+      })
+    );
+
+    element._setDiffViewMode()!.then(() => {
+      assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+      done();
+    });
+  });
+
+  test('existing diffMode overrides preference', done => {
+    element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
+    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+      Promise.resolve({
+        ...createPreferences(),
+        default_diff_view: DiffViewMode.UNIFIED,
+      })
+    );
+    element._setDiffViewMode()!.then(() => {
+      assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
+      done();
+    });
+  });
+
+  test('don’t reload entire page when patchRange changes', () => {
+    const reloadStub = sinon
+      .stub(element, '_reload')
+      .callsFake(() => Promise.resolve([]));
+    const reloadPatchDependentStub = sinon
+      .stub(element, '_reloadPatchNumDependentResources')
+      .callsFake(() => Promise.resolve([undefined, undefined]));
+    const relatedClearSpy = sinon.spy(element.$.relatedChanges, 'clear');
+    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+
+    const value: AppElementChangeViewParams = {
+      ...createAppElementChangeViewParams(),
+      view: GerritView.CHANGE,
+      patchNum: 1 as PatchSetNum,
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+
+    element._initialLoadComplete = true;
+
+    value.basePatchNum = 1 as PatchSetNum;
+    value.patchNum = 2 as PatchSetNum;
+    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 = sinon
+      .stub(element, '_reload')
+      .callsFake(() => Promise.resolve([]));
+    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+    const value: AppElementChangeViewParams = createAppElementChangeViewParams();
+    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 not updated after other action', done => {
+    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+    sinon.stub(element.$.relatedChanges, 'reload');
+    element._reload(true).then(() => {
+      assert.isFalse(navigateToChangeStub.called);
+      done();
+    });
+  });
+
+  test('_computeMergedCommitInfo', () => {
+    const dummyRevs: {[revisionId: string]: RevisionInfo} = {
+      1: createRevision(1),
+      2: createRevision(2),
+    };
+    assert.deepEqual(
+      element._computeMergedCommitInfo('0' as CommitId, dummyRevs),
+      {}
+    );
+    assert.deepEqual(
+      element._computeMergedCommitInfo('1' as CommitId, dummyRevs),
+      dummyRevs[1].commit
+    );
+
+    // Regression test for issue 5337.
+    const commit = element._computeMergedCommitInfo('2' as CommitId, dummyRevs);
+    assert.notDeepEqual(commit, dummyRevs[2]);
+    assert.deepEqual(commit, dummyRevs[2].commit);
+  });
+
+  test('_computeCopyTextForTitle', () => {
+    const change: ChangeInfo = {
+      ...createChange(),
+      _number: 123 as NumericChangeId,
+      subject: 'test subject',
+      revisions: {
+        rev1: createRevision(1),
+        rev3: createRevision(3),
+      },
+      current_revision: 'rev3' as CommitId,
+    };
+    sinon.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: ChangeInfo = {
+      ...createChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev3: createRevision(3),
+      },
+      current_revision: 'rev3' as CommitId,
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+    change = {
+      ...createChange(),
+      revisions: {
+        rev1: createRevision(1),
+      },
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+  });
+
+  test('show commit message edit button', () => {
+    const change = createChange();
+    const mergedChanged: ChangeInfo = {
+      ...createChange(),
+      status: ChangeStatus.MERGED,
+    };
+    assert.isTrue(element._computeHideEditCommitMessage(false, false, change));
+    assert.isTrue(element._computeHideEditCommitMessage(true, true, change));
+    assert.isTrue(element._computeHideEditCommitMessage(false, true, change));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, change));
+    assert.isTrue(
+      element._computeHideEditCommitMessage(true, false, mergedChanged)
+    );
+    assert.isTrue(
+      element._computeHideEditCommitMessage(true, false, change, true)
+    );
+    assert.isFalse(
+      element._computeHideEditCommitMessage(true, false, change, false)
+    );
+  });
+
+  test('_handleCommitMessageSave trims trailing whitespace', () => {
+    element._change = createChange();
+    // Response code is 500, because we want to avoid window reloading
+    const putStub = sinon
+      .stub(element.$.restAPI, 'putChangeCommitMessage')
+      .returns(Promise.resolve(new Response(null, {status: 500})));
+
+    const mockEvent = (content: string) => {
+      return new CustomEvent('', {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: ChangeInfo = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+    };
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      null
+    );
+
+    change = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+    };
+    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: ChangeInfo = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+    };
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      null
+    );
+    change = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+    };
+    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: ChangeInfo = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+    };
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      null
+    );
+    change = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+    };
+    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 => {
+    sinon.stub(element, '_changeChanged');
+    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      Promise.resolve({
+        ...createChange(),
+        labels: {},
+        current_revision: 'foo' as CommitId,
+        revisions: {foo: createRevision()},
+      })
+    );
+
+    element._getChangeDetail().then(() => {
+      assert.isNull(element._change!.topic);
+      done();
+    });
+  });
+
+  test('commit sha is populated from getChangeDetail', done => {
+    sinon.stub(element, '_changeChanged');
+    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      Promise.resolve({
+        ...createChange(),
+        labels: {},
+        current_revision: 'foo' as CommitId,
+        revisions: {foo: createRevision()},
+      })
+    );
+
+    element._getChangeDetail().then(() => {
+      assert.equal('foo', element._commitInfo!.commit);
+      done();
+    });
+  });
+
+  test('edit is added to change', () => {
+    sinon.stub(element, '_changeChanged');
+    const changeRevision = createRevision();
+    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      Promise.resolve({
+        ...createChange(),
+        labels: {},
+        current_revision: 'foo' as CommitId,
+        revisions: {foo: {...changeRevision}},
+      })
+    );
+    const editCommit: CommitInfo = {
+      ...createCommit(),
+      commit: 'bar' as CommitId,
+    };
+    sinon.stub(element, '_getEdit').callsFake(() =>
+      Promise.resolve({
+        base_patch_set_number: 1 as PatchSetNum,
+        commit: {...editCommit},
+        base_revision: 'abc',
+        ref: 'some/ref' as GitRef,
+      })
+    );
+    element._patchRange = {};
+
+    return element._getChangeDetail().then(() => {
+      const revs = element._change!.revisions!;
+      assert.equal(Object.keys(revs).length, 2);
+      assert.deepEqual(revs['foo'], changeRevision);
+      assert.deepEqual(revs['bar'], {
+        ...createEditRevision(),
+        commit: editCommit,
+        fetch: undefined,
+      });
+    });
+  });
+
+  test('_getBasePatchNum', () => {
+    const _change: ChangeInfo = {
+      ...createChange(),
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
+      },
+    };
+    const _patchRange: ChangeViewPatchRange = {
+      basePatchNum: ParentPatchSetNum,
+    };
+    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+
+    element._prefs = {
+      ...createPreferences(),
+      default_base_for_merges: DefaultBase.FIRST_PARENT,
+    };
+
+    const _change2: ChangeInfo = {
+      ...createChange(),
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          ...createRevision(1),
+          commit: {
+            ...createCommit(),
+            parents: [
+              {
+                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8' as CommitId,
+                subject: 'test',
+              },
+              {
+                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841' as CommitId,
+                subject: 'test3',
+              },
+            ],
+          },
+        },
+      },
+    };
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+
+    _patchRange.patchNum = 1 as PatchSetNum;
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+  });
+
+  test('_openReplyDialog called with `ANY` when coming from tap event', done => {
+    flush(() => {
+      const openStub = sinon.stub(element, '_openReplyDialog');
+      tap(element.$.replyBtn);
+      assert(
+        openStub.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.ANY
+        ),
+        '_openReplyDialog should have been passed ANY'
+      );
+      assert.equal(openStub.callCount, 1);
+      done();
+    });
+  });
+
+  test(
+    '_openReplyDialog called with `BODY` when coming from message reply' +
+      'event',
+    done => {
+      flush(() => {
+        const openStub = sinon.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 = sinon.stub(element, '_openReplyDialog');
+
+    const e = new CustomEvent('show-reply-dialog', {
+      detail: {value: {ccsOnly: false}},
+    });
+    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 = sinon.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 => {
+    sinon
+      .stub(element.$.restAPI, 'getLoggedIn')
+      .callsFake(() => Promise.resolve(true));
+    const awaitPluginsLoadedStub = sinon
+      .stub(getPluginLoader(), 'awaitPluginsLoaded')
+      .callsFake(() => Promise.resolve());
+
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 2 as PatchSetNum,
+    };
+    element._change = {
+      ...createChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev2: createRevision(2),
+      },
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.MERGED,
+      labels: {},
+      actions: {},
+    };
+
+    sinon.stub(element, '_getUrlParameter').callsFake(param => {
+      assert.equal(param, 'revert');
+      return param;
+    });
+
+    sinon.stub(element.$.actions, 'showRevertDialog').callsFake(done);
+
+    element._maybeShowRevertDialog();
+    assert.isTrue(awaitPluginsLoadedStub.called);
+  });
+
+  suite('scroll related tests', () => {
+    test('document scrolling calls function to set scroll height', done => {
+      const originalHeight = document.body.scrollHeight;
+      const scrollStub = sinon.stub(element, '_handleScroll').callsFake(() => {
+        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};
+
+      sinon.stub(element, '_reload').callsFake(() => {
+        // 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({...createAppElementChangeViewParams()});
+    });
+
+    test('scrollTop is reset when new change is loaded', () => {
+      element._resetFileListViewState();
+      assert.equal(element.viewState.scrollTop, 0);
+    });
+  });
+
+  suite('reply dialog tests', () => {
+    setup(() => {
+      sinon.stub(element.$.replyDialog, '_draftChanged');
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(1),
+        messages: createChangeMessages(1),
+      };
+      element._change.labels = {};
+      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        Promise.resolve({
+          ...createChange(),
+          // element has latest info
+          revisions: {rev1: createRevision()},
+          messages: createChangeMessages(1),
+          current_revision: 'rev1' as CommitId,
+        })
+      );
+    });
+
+    test('show reply dialog on open-reply-dialog event', done => {
+      const openReplyDialogStub = sinon.stub(element, '_openReplyDialog');
+      element.dispatchEvent(
+        new CustomEvent('open-reply-dialog', {
+          composed: true,
+          bubbles: true,
+          detail: {},
+        })
+      );
+      flush(() => {
+        assert.isTrue(openReplyDialogStub.calledOnce);
+        done();
+      });
+    });
+
+    test('reply from comment adds quote text', () => {
+      const e = new CustomEvent('', {
+        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 = new CustomEvent('', {
+        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 = new CustomEvent('', {
+        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: sinon.spy(),
+      } as unknown) as MouseEvent;
+      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', done => {
+    assert.isTrue(element._replyDisabled);
+    // fetches the server config on attached
+    flush(() => {
+      assert.isFalse(element._replyDisabled);
+      done();
+    });
+  });
+
+  suite('commit message expand/collapse', () => {
+    setup(() => {
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(1),
+        messages: createChangeMessages(1),
+      };
+      element._change.labels = {};
+      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        Promise.resolve({
+          ...createChange(),
+          // new patchset was uploaded
+          revisions: createRevisions(2),
+          current_revision: getCurrentRevision(2),
+          messages: createChangeMessages(1),
+        })
+      );
+    });
+
+    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'));
+      tap(element.$.commitCollapseToggleButton);
+      assert.isFalse(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isFalse(element.$.commitMessageEditor.hasAttribute('collapsed'));
+    });
+  });
+
+  suite('related changes expand/collapse', () => {
+    let updateHeightSpy: SinonSpyMember<typeof element._updateRelatedChangeMaxHeight>;
+    setup(() => {
+      updateHeightSpy = sinon.spy(element, '_updateRelatedChangeMaxHeight');
+    });
+
+    test('relatedChangesToggle shown height greater than changeInfo height', () => {
+      assert.isFalse(
+        element.$.relatedChangesToggle.classList.contains('showToggle')
+      );
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getScrollHeight').callsFake(() => 60);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: true} as MediaQueryList;
+      });
+      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')
+      );
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getScrollHeight').callsFake(() => 40);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: true} as MediaQueryList;
+      });
+      element.$.relatedChanges.dispatchEvent(
+        new CustomEvent('new-section-loaded')
+      );
+      assert.isFalse(
+        element.$.relatedChangesToggle.classList.contains('showToggle')
+      );
+      assert.equal(updateHeightSpy.callCount, 1);
+    });
+
+    test('relatedChangesToggle functions', () => {
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: false} as MediaQueryList;
+      });
+      assert.isTrue(element._relatedChangesCollapsed);
+      assert.isTrue(element.$.relatedChanges.classList.contains('collapsed'));
+      tap(element.$.relatedChangesToggleButton);
+      assert.isFalse(element._relatedChangesCollapsed);
+      assert.isFalse(element.$.relatedChanges.classList.contains('collapsed'));
+    });
+
+    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: false} as MediaQueryList;
+      });
+
+      // 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');
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: false} as MediaQueryList;
+      });
+
+      // 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');
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: true} as MediaQueryList;
+      });
+
+      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');
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      const matchMediaStub = sinon.stub(window, 'matchMedia').callsFake(() => {
+        if (matchMediaStub.lastCall.args[0] === '(max-width: 75em)') {
+          return {matches: true} as MediaQueryList;
+        } else {
+          return {matches: false} as MediaQueryList;
+        }
+      });
+
+      // 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', () => {
+      let startUpdateCheckTimerSpy: SinonSpyMember<typeof element._startUpdateCheckTimer>;
+      let asyncStub: SinonStubbedMember<typeof element.async>;
+      setup(() => {
+        startUpdateCheckTimerSpy = sinon.spy(element, '_startUpdateCheckTimer');
+        asyncStub = sinon.stub(element, 'async').callsFake(f => {
+          // Only fire the async callback one time.
+          if (asyncStub.callCount > 1) {
+            return 1;
+          }
+          f.call(element);
+          return 1;
+        });
+        element._change = {
+          ...createChange(),
+          revisions: createRevisions(1),
+          messages: createChangeMessages(1),
+        };
+      });
+
+      test('_startUpdateCheckTimer negative delay', () => {
+        const getChangeDetailStub = sinon
+          .stub(element.$.restAPI, 'getChangeDetail')
+          .callsFake(() =>
+            Promise.resolve({
+              ...createChange(),
+              // element has latest info
+              revisions: {rev1: createRevision()},
+              messages: createChangeMessages(1),
+              current_revision: 'rev1' as CommitId,
+            })
+          );
+
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: -1},
+        };
+
+        assert.isTrue(startUpdateCheckTimerSpy.called);
+        assert.isFalse(getChangeDetailStub.called);
+      });
+
+      test('_startUpdateCheckTimer up-to-date', async () => {
+        const getChangeDetailStub = sinon
+          .stub(element.$.restAPI, 'getChangeDetail')
+          .callsFake(() =>
+            Promise.resolve({
+              ...createChange(),
+              // element has latest info
+              revisions: {rev1: createRevision()},
+              messages: createChangeMessages(1),
+              current_revision: 'rev1' as CommitId,
+            })
+          );
+
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+        await flush();
+
+        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
+        assert.isTrue(getChangeDetailStub.called);
+        assert.equal(asyncStub.lastCall.args[1], 12345 * 1000);
+      });
+
+      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+          Promise.resolve({
+            ...createChange(),
+            // new patchset was uploaded
+            revisions: createRevisions(2),
+            current_revision: getCurrentRevision(2),
+            messages: createChangeMessages(1),
+          })
+        );
+
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message, 'A newer patch set has been uploaded');
+          done();
+        });
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+
+        assert.equal(startUpdateCheckTimerSpy.callCount, 1);
+      });
+
+      test('_startUpdateCheckTimer respects _loading', async () => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+          Promise.resolve({
+            ...createChange(),
+            // new patchset was uploaded
+            revisions: createRevisions(2),
+            current_revision: getCurrentRevision(2),
+            messages: createChangeMessages(1),
+          })
+        );
+
+        element._loading = true;
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+        await flush();
+
+        // No toast, instead a second call to _startUpdateCheckTimer().
+        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
+      });
+
+      test('_startUpdateCheckTimer new status shows an alert', done => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+          Promise.resolve({
+            ...createChange(),
+            // element has latest info
+            revisions: {rev1: createRevision()},
+            messages: createChangeMessages(1),
+            current_revision: 'rev1' as CommitId,
+            status: ChangeStatus.MERGED,
+          })
+        );
+
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message, 'This change has been merged');
+          done();
+        });
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+      });
+
+      test('_startUpdateCheckTimer new messages shows an alert', done => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+          Promise.resolve({
+            ...createChange(),
+            revisions: {rev1: createRevision()},
+            // element has new message
+            messages: createChangeMessages(2),
+            current_revision: 'rev1' as CommitId,
+          })
+        );
+        element.addEventListener('show-alert', e => {
+          assert.equal(
+            e.detail.message,
+            'There are new messages on this change'
+          );
+          done();
+        });
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+      });
+    });
+
+    test('canStartReview computation', () => {
+      const change1: ChangeInfo = createChange();
+      const change2: ChangeInfo = {
+        ...createChange(),
+        actions: {
+          ready: {
+            enabled: true,
+          },
+        },
+      };
+      const change3: ChangeInfo = {
+        ...createChange(),
+        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 = sinon.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', () => {
+    const reloadStub = sinon.stub(element.$.relatedChanges, 'reload');
+    element.dispatchEvent(new CustomEvent('topic-changed'));
+    assert.isTrue(reloadStub.calledOnce);
+  });
+
+  test('_computeEditMode', () => {
+    const callCompute = (
+      range: PatchRange,
+      params: AppElementChangeViewParams
+    ) =>
+      element._computeEditMode(
+        {base: range, path: '', value: range},
+        {base: params, path: '', value: params}
+      );
+    assert.isTrue(
+      callCompute(
+        {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
+        {...createAppElementChangeViewParams(), edit: true}
+      )
+    );
+    assert.isFalse(
+      callCompute(
+        {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
+        createAppElementChangeViewParams()
+      )
+    );
+    assert.isFalse(
+      callCompute(
+        {basePatchNum: EditPatchSetNum, patchNum: 1 as PatchSetNum},
+        createAppElementChangeViewParams()
+      )
+    );
+    assert.isTrue(
+      callCompute(
+        {basePatchNum: 1 as PatchSetNum, patchNum: EditPatchSetNum},
+        createAppElementChangeViewParams()
+      )
+    );
+  });
+
+  test('_processEdit', () => {
+    element._patchRange = {};
+    const change: ParsedChangeInfo = {
+      ...createChange(),
+      current_revision: 'foo' as CommitId,
+      revisions: {
+        foo: {...createRevision(), actions: {cherrypick: {enabled: true}}},
+      },
+    };
+    let mockChange;
+
+    // With no edit, mockChange should be unmodified.
+    element._processEdit((mockChange = _.cloneDeep(change)), false);
+    assert.deepEqual(mockChange, change);
+
+    const editCommit: CommitInfo = {
+      ...createCommit(),
+      commit: 'bar' as CommitId,
+    };
+    // When edit is not based on the latest PS, current_revision should be
+    // unmodified.
+    const edit: EditInfo = {
+      ref: 'ref/test/abc' as GitRef,
+      base_revision: 'abc',
+      base_patch_set_number: 1 as PatchSetNum,
+      commit: {...editCommit},
+      fetch: {},
+    };
+    element._processEdit((mockChange = _.cloneDeep(change)), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.revisions.bar._number, EditPatchSetNum);
+    assert.equal(mockChange.current_revision, change.current_revision);
+    assert.deepEqual(mockChange.revisions.bar.commit, editCommit);
+    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 = 5 as PatchSetNum;
+    change.current_revision = 'baz' as CommitId;
+    element._processEdit((mockChange = _.cloneDeep(change)), edit);
+    assert.equal(element._patchRange.patchNum, 5 as PatchSetNum);
+    assert.notOk(mockChange.revisions.bar.actions);
+  });
+
+  test('file-action-tap handling', () => {
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 1 as PatchSetNum,
+    };
+    element._change = {
+      ...createChange(),
+    };
+    const fileList = element.$.fileList;
+    const Actions = GrEditConstants.Actions;
+    element.$.fileListHeader.editMode = true;
+    flush();
+    const controls = element.$.fileListHeader.shadowRoot!.querySelector(
+      '#editControls'
+    ) as GrEditControls;
+    const openDeleteDialogStub = sinon.stub(controls, 'openDeleteDialog');
+    const openRenameDialogStub = sinon.stub(controls, 'openRenameDialog');
+    const openRestoreDialogStub = sinon.stub(controls, 'openRestoreDialog');
+    const getEditUrlForDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
+    const navigateToRelativeUrlStub = sinon.stub(
+      GerritNav,
+      'navigateToRelativeUrl'
+    );
+
+    // Delete
+    fileList.dispatchEvent(
+      new CustomEvent('file-action-tap', {
+        detail: {action: Actions.DELETE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    flush();
+
+    assert.isTrue(openDeleteDialogStub.called);
+    assert.equal(openDeleteDialogStub.lastCall.args[0], 'foo');
+
+    // Restore
+    fileList.dispatchEvent(
+      new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RESTORE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    flush();
+
+    assert.isTrue(openRestoreDialogStub.called);
+    assert.equal(openRestoreDialogStub.lastCall.args[0], 'foo');
+
+    // Rename
+    fileList.dispatchEvent(
+      new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RENAME.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    flush();
+
+    assert.isTrue(openRenameDialogStub.called);
+    assert.equal(openRenameDialogStub.lastCall.args[0], 'foo');
+
+    // Open
+    fileList.dispatchEvent(
+      new CustomEvent('file-action-tap', {
+        detail: {action: Actions.OPEN.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    flush();
+
+    assert.isTrue(getEditUrlForDiffStub.called);
+    assert.equal(getEditUrlForDiffStub.lastCall.args[1], 'foo');
+    assert.equal(getEditUrlForDiffStub.lastCall.args[2], 1 as PatchSetNum);
+    assert.isTrue(navigateToRelativeUrlStub.called);
+  });
+
+  test('_selectedRevision updates when patchNum is changed', () => {
+    const revision1: RevisionInfo = createRevision(1);
+    const revision2: RevisionInfo = createRevision(2);
+    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+      Promise.resolve({
+        ...createChange(),
+        revisions: {
+          aaa: revision1,
+          bbb: revision2,
+        },
+        labels: {},
+        actions: {},
+        current_revision: 'bbb' as CommitId,
+      })
+    );
+    sinon.stub(element, '_getEdit').returns(Promise.resolve(false));
+    sinon
+      .stub(element, '_getPreferences')
+      .returns(Promise.resolve(createPreferences()));
+    element._patchRange = {patchNum: 2 as PatchSetNum};
+    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 = createRevision(1);
+    const revision2 = createRevision(2);
+    const revision3 = createEditRevision();
+    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+      Promise.resolve({
+        ...createChange(),
+        revisions: {
+          aaa: revision1,
+          bbb: revision2,
+          ccc: revision3,
+        },
+        labels: {},
+        actions: {},
+        current_revision: 'ccc' as CommitId,
+      })
+    );
+    sinon.stub(element, '_getEdit').returns(Promise.resolve(undefined));
+    sinon
+      .stub(element, '_getPreferences')
+      .returns(Promise.resolve(createPreferences()));
+    element._patchRange = {patchNum: EditPatchSetNum};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision3);
+    });
+  });
+
+  test('_sendShowChangeEvent', () => {
+    const change = {...createChange(), labels: {}};
+    element._change = {...change};
+    element._patchRange = {patchNum: 4 as PatchSetNum};
+    element._mergeable = true;
+    const showStub = sinon.stub(element.$.jsAPI, 'handleEvent');
+    element._sendShowChangeEvent();
+    assert.isTrue(showStub.calledOnce);
+    assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
+    assert.deepEqual(showStub.lastCall.args[1], {
+      change,
+      patchNum: 4,
+      info: {mergeable: true},
+    });
+  });
+
+  suite('_handleEditTap', () => {
+    let fireEdit: () => void;
+
+    setup(() => {
+      fireEdit = () => {
+        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
+      };
+      navigateToChangeStub.restore();
+
+      element._change = {
+        ...createChange(),
+        revisions: {rev1: createRevision()},
+      };
+    });
+
+    test('edit exists in revisions', done => {
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+        assert.equal(args.length, 2);
+        assert.equal(args[1], EditPatchSetNum); // patchNum
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {
+        _number: SPECIAL_PATCH_SET_NUM.EDIT,
+      });
+      flush();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, non-latest patchset', done => {
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+        assert.equal(args.length, 4);
+        assert.equal(args[1], 1 as PatchSetNum); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 1 as PatchSetNum};
+      flush();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, latest patchset', done => {
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((...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 as PatchSetNum};
+      flush();
+
+      fireEdit();
+    });
+  });
+
+  test('_handleStopEditTap', done => {
+    element._change = {
+      ...createChange(),
+    };
+    sinon.stub(element.$.metadata, '_computeLabelNames');
+    navigateToChangeStub.restore();
+    sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+      assert.equal(args.length, 2);
+      assert.equal(args[1], 1 as PatchSetNum); // patchNum
+      done();
+    });
+
+    element._patchRange = {patchNum: 1 as PatchSetNum};
+    element.$.actions.dispatchEvent(
+      new CustomEvent('stop-edit-tap', {bubbles: false})
+    );
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element._change = {...createChange(), labels: {}};
+      element._selectedRevision = createRevision();
+      let hookEl: HTMLElement;
+      let plugin: PluginApi;
+      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 as any).plugin, plugin);
+        assert.strictEqual((hookEl as any).change, element._change);
+        assert.strictEqual((hookEl as any).revision, element._selectedRevision);
+        done();
+      });
+    });
+  });
+
+  suite('_getMergeability', () => {
+    let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
+    setup(() => {
+      element._change = {...createChange(), labels: {}};
+      getMergeableStub = sinon
+        .stub(element.$.restAPI, 'getMergeable')
+        .returns(Promise.resolve({...createMergeable(), mergeable: true}));
+    });
+
+    test('merged change', () => {
+      element._mergeable = null;
+      element._change!.status = ChangeStatus.MERGED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('abandoned change', () => {
+      element._mergeable = null;
+      element._change!.status = 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', () => {
+    sinon.stub(element.$.relatedChanges, 'reload');
+    sinon.stub(element, '_reload').returns(Promise.resolve([]));
+    const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+    element._paramsChanged({
+      view: GerritNav.View.CHANGE,
+      changeNum: 101 as NumericChangeId,
+      project: TEST_PROJECT_NAME,
+    });
+    assert.isTrue(setStub.calledOnce);
+    assert.isTrue(
+      setStub.calledWith(101 as NumericChangeId, TEST_PROJECT_NAME)
+    );
+  });
+
+  test('_handleToggleStar called when star is tapped', () => {
+    element._change = {
+      ...createChange(),
+      owner: {_account_id: 1 as AccountId},
+      starred: false,
+    };
+    element._loggedIn = true;
+    const stub = sinon.stub(element, '_handleToggleStar');
+    flush();
+
+    tap(element.$.changeStar.shadowRoot!.querySelector('button')!);
+    assert.isTrue(stub.called);
+  });
+
+  suite('gr-reporting tests', () => {
+    setup(() => {
+      element._patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as PatchSetNum,
+      };
+      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
+      sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
+      sinon.stub(element, '_reloadComments').returns(Promise.resolve());
+      sinon.stub(element, '_getMergeability').returns(Promise.resolve());
+      sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
+    });
+
+    test("don't report changedDisplayed on reply", done => {
+      const changeDisplayStub = sinon.stub(
+        element.reporting,
+        'changeDisplayed'
+      );
+      const changeFullyLoadedStub = sinon.stub(
+        element.reporting,
+        'changeFullyLoaded'
+      );
+      element._handleReplySent();
+      flush(() => {
+        assert.isFalse(changeDisplayStub.called);
+        assert.isFalse(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+
+    test('report changedDisplayed on _paramsChanged', done => {
+      const changeDisplayStub = sinon.stub(
+        element.reporting,
+        'changeDisplayed'
+      );
+      const changeFullyLoadedStub = sinon.stub(
+        element.reporting,
+        'changeFullyLoaded'
+      );
+      element._paramsChanged({
+        ...createAppElementChangeViewParams(),
+        changeNum: 101 as NumericChangeId,
+        project: TEST_PROJECT_NAME,
+      });
+      flush(() => {
+        assert.isTrue(changeDisplayStub.called);
+        assert.isTrue(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+  });
+});
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
deleted file mode 100644
index 7ca9d6b..0000000
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ /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.
- */
-/*
-  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!
-*/
-
-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';
-
-/**
- * @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);
-  }
-
-  _isOnParent(comment) {
-    return comment.side === 'PARENT';
-  }
-
-  _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);
-  }
-
-  _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));
-  }
-
-  _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
deleted file mode 100644
index 60b83ee..0000000
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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
deleted file mode 100644
index 075b883..0000000
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ /dev/null
@@ -1,132 +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">
-<meta charset="utf-8">
-<title>gr-comment-list</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-comment-list></gr-comment-list>
-  </template>
-</test-fixture>
-
-<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';
-
-suite('gr-comment-list tests', () => {
-  let element;
-  let sandbox;
-
-  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.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
deleted file mode 100644
index 4dba3af..0000000
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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,
-      serverConfig: Object,
-      _showWebLink: {
-        type: Boolean,
-        computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
-      },
-      _webLink: {
-        type: String,
-        computed: '_computeWebLink(change, commitInfo, serverConfig)',
-      },
-    };
-  }
-
-  _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;
-    }
-
-    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;
-    }
-
-    const {url} = this._getWeblink(change, commitInfo, serverConfig) || {};
-    return url;
-  }
-
-  _computeShortHash(commitInfo) {
-    const {name} =
-          this._getWeblink(this.change, commitInfo, this.serverConfig) || {};
-    return name;
-  }
-}
-
-customElements.define(GrCommitInfo.is, GrCommitInfo);
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
new file mode 100644
index 0000000..18bd3a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -0,0 +1,100 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-commit-info_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, computed} from '@polymer/decorators';
+import {ChangeInfo, CommitInfo, ServerInfo} from '../../../types/common';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-commit-info': GrCommitInfo;
+  }
+}
+
+@customElement('gr-commit-info')
+export class GrCommitInfo extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  // TODO(TS): can not use `?` here as @computed require dependencies as
+  // not optional
+  @property({type: Object})
+  change: ChangeInfo | undefined;
+
+  // TODO(TS): maybe limit to StandaloneCommitInfo if never pass in
+  // with commit inside RevisionInfo
+  @property({type: Object})
+  commitInfo: CommitInfo | undefined;
+
+  @property({type: Object})
+  serverConfig: ServerInfo | undefined;
+
+  @computed('change', 'commitInfo', 'serverConfig')
+  get _showWebLink(): boolean {
+    if (!this.change || !this.commitInfo || !this.serverConfig) {
+      return false;
+    }
+
+    const weblink = this._getWeblink(
+      this.change,
+      this.commitInfo,
+      this.serverConfig
+    );
+    return !!weblink && !!weblink.url;
+  }
+
+  @computed('change', 'commitInfo', 'serverConfig')
+  get _webLink(): string | undefined {
+    if (!this.change || !this.commitInfo || !this.serverConfig) {
+      return '';
+    }
+
+    // TODO(TS): if getPatchSetWeblink always return a valid WebLink,
+    // can remove the fallback here
+    const {url} =
+      this._getWeblink(this.change, this.commitInfo, this.serverConfig) || {};
+    return url;
+  }
+
+  _getWeblink(change: ChangeInfo, commitInfo: CommitInfo, config: ServerInfo) {
+    return GerritNav.getPatchSetWeblink(change.project, commitInfo.commit, {
+      weblinks: commitInfo.web_links,
+      config,
+    });
+  }
+
+  _computeShortHash(
+    change?: ChangeInfo,
+    commitInfo?: CommitInfo,
+    serverConfig?: ServerInfo
+  ) {
+    if (!change || !commitInfo || !serverConfig) {
+      return '';
+    }
+
+    const {name} = this._getWeblink(change, commitInfo, serverConfig) || {};
+    return name;
+  }
+}
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
deleted file mode 100644
index 608d12b..0000000
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
new file mode 100644
index 0000000..df0bb4a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
@@ -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';
+
+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(change, commitInfo, serverConfig)]]</a
+      >
+    </template>
+    <template is="dom-if" if="[[!_showWebLink]]">
+      [[_computeShortHash(change, commitInfo, serverConfig)]]
+    </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
deleted file mode 100644
index d9664ec..0000000
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ /dev/null
@@ -1,139 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-commit-info</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-commit-info></gr-commit-info>
-  </template>
-</test-fixture>
-
-<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';
-
-suite('gr-commit-info tests', () => {
-  let element;
-  let sandbox;
-
-  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-commit-info/gr-commit-info_test.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js
new file mode 100644
index 0000000..ffaed23
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-commit-info.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-commit-info');
+
+suite('gr-commit-info tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('weblinks use GerritNav interface', () => {
+    const weblinksStub = sinon.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._showWebLink);
+  });
+
+  test('use web link when available', () => {
+    const router = document.createElement('gr-router');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        router._generateWeblinks.bind(router));
+
+    element.change = {labels: [], project: ''};
+    element.commitInfo =
+        {commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
+    element.serverConfig = {};
+
+    assert.isOk(element._showWebLink);
+    assert.equal(element._webLink, 'link-url');
+  });
+
+  test('does not relativize web links that begin with scheme', () => {
+    const router = document.createElement('gr-router');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        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._showWebLink);
+    assert.equal(element._webLink, 'https://link-url');
+  });
+
+  test('ignore web links that are neither gitweb nor gitiles', () => {
+    const router = document.createElement('gr-router');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        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._showWebLink);
+    assert.equal(element._webLink, 'https://link-url');
+
+    // Remove gitiles link.
+    element.commitInfo = {
+      commit: 'commit-sha',
+      web_links: [
+        {
+          name: 'ignore',
+          url: 'ignore',
+        },
+      ],
+    };
+    assert.isNotOk(element._showWebLink);
+    assert.isNotOk(element._webLink);
+  });
+});
+
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
deleted file mode 100644
index d28e2b7..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@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 {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';
-
-/**
- * @extends Polymer.Element
- */
-class GrConfirmAbandonDialog extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-confirm-abandon-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,
-    };
-  }
-
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter': '_handleEnterKey',
-    };
-  }
-
-  resetFocus() {
-    this.$.messageInput.textarea.focus();
-  }
-
-  _handleEnterKey(e) {
-    this._confirm();
-  }
-
-  _handleConfirmTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this._confirm();
-  }
-
-  _confirm() {
-    this.dispatchEvent(new CustomEvent('confirm', {
-      detail: {reason: this.message},
-      composed: true, 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.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
new file mode 100644
index 0000000..10563ee
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -0,0 +1,106 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-abandon-dialog_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+
+export interface GrConfirmAbandonDialog {
+  $: {
+    messageInput: IronAutogrowTextareaElement;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-abandon-dialog': GrConfirmAbandonDialog;
+  }
+}
+
+/**
+ * @extends PolymerElement
+ */
+@customElement('gr-confirm-abandon-dialog')
+export class GrConfirmAbandonDialog extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: String})
+  message?: string;
+
+  get keyBindings() {
+    return {
+      'ctrl+enter meta+enter': '_handleEnterKey',
+    };
+  }
+
+  resetFocus() {
+    this.$.messageInput.textarea.focus();
+  }
+
+  _handleEnterKey() {
+    this._confirm();
+  }
+
+  _handleConfirmTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this._confirm();
+  }
+
+  _confirm() {
+    this.dispatchEvent(
+      new CustomEvent('confirm', {
+        detail: {reason: this.message},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCancelTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+}
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
deleted file mode 100644
index 050df25..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts
new file mode 100644
index 0000000..7c1b725
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.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.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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
deleted file mode 100644
index 8010814..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
+++ /dev/null
@@ -1,83 +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">
-<meta charset="utf-8">
-<title>gr-confirm-abandon-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-abandon-dialog></gr-confirm-abandon-dialog>
-  </template>
-</test-fixture>
-
-<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.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-abandon-dialog/gr-confirm-abandon-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js
new file mode 100644
index 0000000..14d16f5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js
@@ -0,0 +1,62 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-confirm-abandon-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-abandon-dialog');
+
+suite('gr-confirm-abandon-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sinon.spy(element, '_handleConfirmTap');
+    sinon.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.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sinon.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);
+  });
+});
+
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
deleted file mode 100644
index 480e6cf..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrConfirmCherrypickConflictDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-confirm-cherrypick-conflict-dialog'; }
-
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  _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.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
new file mode 100644
index 0000000..2f33858
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -0,0 +1,72 @@
+/**
+ * @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.
+ */
+import '../../../styles/shared-styles';
+import '../../shared/gr-dialog/gr-dialog';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-cherrypick-conflict-dialog_html';
+import {customElement} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-cherrypick-conflict-dialog': GrConfirmCherrypickConflictDialog;
+  }
+}
+
+@customElement('gr-confirm-cherrypick-conflict-dialog')
+export class GrConfirmCherrypickConflictDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  _handleConfirmTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCancelTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+}
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
deleted file mode 100644
index c7fb70c..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts
new file mode 100644
index 0000000..5cf56b5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index e0016f0..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
+++ /dev/null
@@ -1,78 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-cherrypick-conflict-dialog></gr-confirm-cherrypick-conflict-dialog>
-  </template>
-</test-fixture>
-
-<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.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-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js
new file mode 100644
index 0000000..c98353b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js
@@ -0,0 +1,61 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-cherrypick-conflict-dialog.js';
+
+const basicFixture =
+    fixtureFromElement('gr-confirm-cherrypick-conflict-dialog');
+
+suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sinon.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 = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sinon.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);
+  });
+});
+
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
deleted file mode 100644
index 2802046..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ /dev/null
@@ -1,310 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const SUGGESTIONS_LIMIT = 15;
-const CHANGE_SUBJECT_LIMIT = 50;
-const CHERRY_PICK_TYPES = {
-  SINGLE_CHANGE: 1,
-  TOPIC: 2,
-};
-
-/**
- * @extends Polymer.Element
- */
-class GrConfirmCherrypickDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-confirm-cherrypick-dialog'; }
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * 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,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_computeMessage(changeStatus, commitNum, commitMessage)',
-    ];
-  }
-
-  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;
-  }
-
-  _updateBranch(branch) {
-    const invalidChars = [',', ' '];
-    this._invalidBranch = branch && invalidChars.some(c => branch.includes(c));
-  }
-
-  _computeTopicErrorMessage(duplicateProjectChanges) {
-    if (duplicateProjectChanges) {
-      return 'Two changes cannot be of the same project';
-    }
-  }
-
-  updateStatus(change, status) {
-    this._statuses = Object.assign({}, this._statuses, {[change.id]: status});
-  }
-
-  _computeStatus(change, statuses) {
-    if (!change || !statuses || !statuses[change.id]) return 'NOT STARTED';
-    return statuses[change.id].status;
-  }
-
-  _computeStatusClass(change, statuses) {
-    if (!change || !statuses || !statuses[change.id]) return '';
-    return statuses[change.id].status === 'FAILED' ? 'error': '';
-  }
-
-  _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}"`);
-        }
-      });
-    });
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
new file mode 100644
index 0000000..e05bac0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -0,0 +1,391 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-cherrypick-dialog_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {
+  ChangeInfo,
+  BranchInfo,
+  RepoName,
+  BranchName,
+  CommitId,
+} from '../../../types/common';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  GrAutocomplete,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {HttpMethod, ChangeStatus} from '../../../constants/constants';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const SUGGESTIONS_LIMIT = 15;
+const CHANGE_SUBJECT_LIMIT = 50;
+enum CherryPickType {
+  SINGLE_CHANGE = 1,
+  TOPIC,
+}
+
+type Statuses = {[changeId: string]: Status};
+
+// TODO(TS): maybe convert status to an enum
+interface Status {
+  status: string;
+  msg?: string;
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-cherrypick-dialog': GrConfirmCherrypickDialog;
+  }
+}
+
+// TODO(TS): add type after gr-autocomplete and gr-rest-api-interface
+// is converted
+export interface GrConfirmCherrypickDialog {
+  $: {
+    restAPI: RestApiService & Element;
+    branchInput: GrAutocomplete;
+  };
+}
+
+@customElement('gr-confirm-cherrypick-dialog')
+export class GrConfirmCherrypickDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: String})
+  branch?: BranchName;
+
+  @property({type: String})
+  baseCommit?: string;
+
+  @property({type: String})
+  changeStatus?: ChangeStatus;
+
+  @property({type: String})
+  commitMessage?: string;
+
+  @property({type: String})
+  commitNum?: CommitId;
+
+  @property({type: String})
+  message?: string;
+
+  @property({type: String})
+  project?: RepoName;
+
+  @property({type: Array})
+  changes: ChangeInfo[] = [];
+
+  @property({type: Object})
+  _query: (input: string) => Promise<AutocompleteSuggestion[]>;
+
+  @property({type: Boolean})
+  _showCherryPickTopic = false;
+
+  @property({type: Number})
+  _changesCount?: number;
+
+  @property({type: Number})
+  _cherryPickType = CherryPickType.SINGLE_CHANGE;
+
+  @property({type: Boolean})
+  _duplicateProjectChanges = false;
+
+  @property({type: Object})
+  // Status of each change that is being cherry picked together
+  _statuses: Statuses;
+
+  @property({type: Boolean})
+  _invalidBranch = false;
+
+  @property({type: Object})
+  reporting: ReportingService;
+
+  constructor() {
+    super();
+    this._statuses = {};
+    this.reporting = appContext.reportingService;
+    this._query = (text: string) => this._getProjectBranchesSuggestions(text);
+  }
+
+  updateChanges(changes: ChangeInfo[]) {
+    this.changes = changes;
+    this._statuses = {};
+    const projects: {[projectName: string]: boolean} = {};
+    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;
+  }
+
+  @observe('branch')
+  _updateBranch(branch: string) {
+    const invalidChars = [',', ' '];
+    this._invalidBranch = !!(
+      branch && invalidChars.some(c => branch.includes(c))
+    );
+  }
+
+  _computeTopicErrorMessage(duplicateProjectChanges: boolean) {
+    if (duplicateProjectChanges) {
+      return 'Two changes cannot be of the same project';
+    }
+    return '';
+  }
+
+  updateStatus(change: ChangeInfo, status: Status) {
+    this._statuses = {...this._statuses, [change.id]: status};
+  }
+
+  _computeStatus(change: ChangeInfo, statuses: Statuses) {
+    if (!change || !statuses || !statuses[change.id]) return 'NOT STARTED';
+    return statuses[change.id].status;
+  }
+
+  _computeStatusClass(change: ChangeInfo, statuses: Statuses) {
+    if (!change || !statuses || !statuses[change.id]) return '';
+    return statuses[change.id].status === 'FAILED' ? 'error' : '';
+  }
+
+  _computeError(change: ChangeInfo, statuses: Statuses) {
+    if (!change || !statuses || !statuses[change.id]) return '';
+    if (statuses[change.id].status === 'FAILED') {
+      return statuses[change.id].msg;
+    }
+    return '';
+  }
+
+  _getChangeId(change: ChangeInfo) {
+    return change.change_id.substring(0, 10);
+  }
+
+  _getTrimmedChangeSubject(subject: string) {
+    if (!subject) return '';
+    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+  }
+
+  _computeCancelLabel(statuses: Statuses) {
+    const isRunningChange = Object.values(statuses).some(
+      v => v.status === 'RUNNING'
+    );
+    return isRunningChange ? 'Close' : 'Cancel';
+  }
+
+  _computeDisableCherryPick(
+    cherryPickType: CherryPickType,
+    duplicateProjectChanges: boolean,
+    statuses: Statuses
+  ) {
+    const duplicateProject =
+      cherryPickType === CherryPickType.TOPIC && duplicateProjectChanges;
+    if (duplicateProject) return true;
+    if (!statuses) return false;
+    const isRunningChange = Object.values(statuses).some(
+      v => v.status === 'RUNNING'
+    );
+    return isRunningChange;
+  }
+
+  _computeIfSinglecherryPick(cherryPickType: CherryPickType) {
+    return cherryPickType === CherryPickType.SINGLE_CHANGE;
+  }
+
+  _computeIfCherryPickTopic(cherryPickType: CherryPickType) {
+    return cherryPickType === CherryPickType.TOPIC;
+  }
+
+  _handlecherryPickSingleChangeClicked() {
+    this._cherryPickType = CherryPickType.SINGLE_CHANGE;
+  }
+
+  _handlecherryPickTopicClicked() {
+    this._cherryPickType = CherryPickType.TOPIC;
+  }
+
+  @observe('changeStatus', 'commitNum', 'commitMessage')
+  _computeMessage(
+    changeStatus?: string,
+    commitNum?: number,
+    commitMessage?: string
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      changeStatus === undefined ||
+      commitNum === undefined ||
+      commitMessage === undefined
+    ) {
+      return;
+    }
+
+    let newMessage = commitMessage;
+
+    if (changeStatus === 'MERGED') {
+      newMessage += '(cherry picked from commit ' + commitNum.toString() + ')';
+    }
+    this.message = newMessage;
+  }
+
+  _generateRandomCherryPickTopic(change: ChangeInfo) {
+    const randomString = Math.random().toString(36).substr(2, 10);
+    const message = `cherrypick-${change.topic}-${randomString}`;
+    return message;
+  }
+
+  _handleCherryPickFailed(change: ChangeInfo, response?: Response | null) {
+    if (!response) return;
+    response.text().then((errText: string) => {
+      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?: Response | null) => {
+        this._handleCherryPickFailed(change, response);
+      };
+      // revisions and current_revision must exist hence casting
+      const patchNum = change.revisions![change.current_revision!]._number;
+      this.$.restAPI
+        .executeChangeAction(
+          change._number,
+          HttpMethod.POST,
+          '/cherrypick',
+          patchNum,
+          payload,
+          handleError
+        )
+        .then(() => {
+          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}"`);
+          }
+        });
+    });
+  }
+
+  _handleConfirmTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    if (this._cherryPickType === CherryPickType.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: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  resetFocus() {
+    this.$.branchInput.focus();
+  }
+
+  _getProjectBranchesSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    if (!this.project) {
+      console.error('no project specified');
+      return Promise.resolve([]);
+    }
+    if (input.startsWith('refs/heads/')) {
+      input = input.substring('refs/heads/'.length);
+    }
+    return this.$.restAPI
+      .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+      .then((response: BranchInfo[] | undefined) => {
+        const branches = [];
+        if (!response) return [];
+        let branch;
+        for (const key in response) {
+          if (!hasOwnProperty(response, 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;
+      });
+  }
+}
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
deleted file mode 100644
index aeb8061..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js
+++ /dev/null
@@ -1,220 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
new file mode 100644
index 0000000..072f110
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
@@ -0,0 +1,216 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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-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
deleted file mode 100644
index 000718b..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
+++ /dev/null
@@ -1,185 +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">
-<meta charset="utf-8">
-<title>gr-confirm-cherrypick-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-cherrypick-dialog></gr-confirm-cherrypick-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-cherrypick-dialog.js';
-
-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},
-        },
-        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;
-    });
-
-    test('cherry pick topic submit', done => {
-      element.branch = 'master';
-      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('_computeStatusClass', () => {
-      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'RUNNING'},
-      }), '');
-      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'FAILED'}}
-      ), 'error');
-    });
-
-    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-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
new file mode 100644
index 0000000..07f8f63
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -0,0 +1,165 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-cherrypick-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-cherrypick-dialog');
+
+const CHERRY_PICK_TYPES = {
+  SINGLE_CHANGE: 1,
+  TOPIC: 2,
+};
+suite('gr-confirm-cherrypick-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 = basicFixture.instantiate();
+    element.project = 'test-project';
+  });
+
+  test('with merged change', () => {
+    element.changeStatus = 'MERGED';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    flush();
+    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';
+    flush();
+    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;
+    flush();
+    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},
+        },
+        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;
+    });
+
+    test('cherry pick topic submit', done => {
+      element.branch = 'master';
+      const executeChangeActionStub = sinon.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('_computeStatusClass', () => {
+      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'RUNNING'},
+      }), '');
+      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'FAILED'}}
+      ), 'error');
+    });
+
+    test('submit button is blocked while cherry picks is running', done => {
+      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 = sinon.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();
+    });
+  });
+});
+
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
deleted file mode 100644
index 25beb2d..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
+++ /dev/null
@@ -1,116 +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.
- */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-
-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';
-
-const SUGGESTIONS_LIMIT = 15;
-
-/**
- * @extends Polymer.Element
- */
-class GrConfirmMoveDialog extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-confirm-move-dialog'; }
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  static get properties() {
-    return {
-      branch: String,
-      message: String,
-      project: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getProjectBranchesSuggestions.bind(this);
-        },
-      },
-    };
-  }
-
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter': '_handleConfirmTap',
-    };
-  }
-
-  _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,
-    }));
-  }
-
-  _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(GrConfirmMoveDialog.is, GrConfirmMoveDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
new file mode 100644
index 0000000..7150ddf
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -0,0 +1,137 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-move-dialog_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {RepoName, BranchName} from '../../../types/common';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+const SUGGESTIONS_LIMIT = 15;
+
+export interface GrConfirmMoveDialog {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-confirm-move-dialog')
+export class GrConfirmMoveDialog extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: String})
+  branch?: BranchName;
+
+  @property({type: String})
+  message?: string;
+
+  @property({type: String})
+  project?: RepoName;
+
+  @property({type: Object})
+  _query: (input: string) => Promise<AutocompleteSuggestion[]>;
+
+  get keyBindings() {
+    return {
+      'ctrl+enter meta+enter': '_handleConfirmTap',
+    };
+  }
+
+  constructor() {
+    super();
+    this._query = (text: string) => this._getProjectBranchesSuggestions(text);
+  }
+
+  _handleConfirmTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCancelTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _getProjectBranchesSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    if (!this.project) return Promise.reject(new Error('Missing project'));
+    if (input.startsWith('refs/heads/')) {
+      input = input.substring('refs/heads/'.length);
+    }
+    return this.$.restAPI
+      .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+      .then(response => {
+        const branches: AutocompleteSuggestion[] = [];
+        let branch;
+        if (response) {
+          response.forEach(value => {
+            if (value.ref.startsWith('refs/heads/')) {
+              branch = value.ref.substring('refs/heads/'.length);
+            } else {
+              branch = value.ref;
+            }
+            branches.push({
+              name: branch,
+            });
+          });
+        }
+
+        return branches;
+      });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-move-dialog': 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
deleted file mode 100644
index f5ddf41..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
new file mode 100644
index 0000000..b5b46d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index a8392aa..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
+++ /dev/null
@@ -1,83 +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">
-<meta charset="utf-8">
-<title>gr-confirm-move-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-move-dialog></gr-confirm-move-dialog>
-  </template>
-</test-fixture>
-
-<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';
-  });
-
-  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();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
new file mode 100644
index 0000000..d51388e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
@@ -0,0 +1,76 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-confirm-move-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
+
+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(undefined);
+        }
+      },
+    });
+    element = basicFixture.instantiate();
+    element.project = 'test-project';
+  });
+
+  test('with updated commit message', () => {
+    element.branch = 'master';
+    const myNewMessage = 'updated commit message';
+    element.message = myNewMessage;
+    flush();
+    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 input empty string', done => {
+    element._getProjectBranchesSuggestions('').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
+    });
+  });
+});
+
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
deleted file mode 100644
index e451034..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ /dev/null
@@ -1,179 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.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 '../../../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';
-
-/** @extends Polymer.Element */
-class GrConfirmRebaseDialog extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-confirm-rebase-dialog'; }
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  static get properties() {
-    return {
-      branch: String,
-      changeNumber: Number,
-      hasParent: Boolean,
-      rebaseOnCurrent: Boolean,
-      _text: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getChangeSuggestions.bind(this);
-        },
-      },
-      _recentChanges: Array,
-    };
-  }
-
-  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;
-        });
-  }
-
-  _getRecentChanges() {
-    if (this._recentChanges) {
-      return Promise.resolve(this._recentChanges);
-    }
-    return this.fetchRecentChanges();
-  }
-
-  _getChangeSuggestions(input) {
-    return this._getRecentChanges().then(changes =>
-      this._filterChanges(input, changes));
-  }
-
-  _filterChanges(input, changes) {
-    return changes.filter(change => change.name.includes(input) &&
-        change.value !== this.changeNumber);
-  }
-
-  _displayParentOption(rebaseOnCurrent, hasParent) {
-    return hasParent && rebaseOnCurrent;
-  }
-
-  _displayParentUpToDateMsg(rebaseOnCurrent, hasParent) {
-    return hasParent && !rebaseOnCurrent;
-  }
-
-  _displayTipOption(rebaseOnCurrent, hasParent) {
-    return !(!rebaseOnCurrent && !hasParent);
-  }
-
-  /**
-   * There is a subtle but important difference between setting the base to an
-   * 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 = '';
-  }
-
-  _handleCancelTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('cancel'));
-    this._text = '';
-  }
-
-  _handleRebaseOnOther() {
-    this.$.parentInput.focus();
-  }
-
-  _handleEnterChangeNumberClick() {
-    this.$.rebaseOnOtherInput.checked = true;
-  }
-
-  /**
-   * Sets the default radio button based on the state of the app and
-   * 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.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
new file mode 100644
index 0000000..db0e1ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -0,0 +1,241 @@
+/**
+ * @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 '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-rebase-dialog_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {NumericChangeId, BranchName} from '../../../types/common';
+import {
+  GrAutocomplete,
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+interface RebaseChange {
+  name: string;
+  value: NumericChangeId;
+}
+
+export interface ConfirmRebaseEventDetail {
+  base: string | null;
+}
+
+export interface GrConfirmRebaseDialog {
+  $: {
+    restAPI: RestApiService & Element;
+    parentInput: GrAutocomplete;
+    rebaseOnParentInput: HTMLInputElement;
+    rebaseOnOtherInput: HTMLInputElement;
+    rebaseOnTipInput: HTMLInputElement;
+  };
+}
+
+@customElement('gr-confirm-rebase-dialog')
+export class GrConfirmRebaseDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: String})
+  branch?: BranchName;
+
+  @property({type: Number})
+  changeNumber?: NumericChangeId;
+
+  @property({type: Boolean})
+  hasParent?: boolean;
+
+  @property({type: Boolean})
+  rebaseOnCurrent?: boolean;
+
+  @property({type: String})
+  _text?: string;
+
+  @property({type: Object})
+  _query?: AutocompleteQuery;
+
+  @property({type: Array})
+  _recentChanges?: RebaseChange[];
+
+  constructor() {
+    super();
+    this._query = input => this._getChangeSuggestions(input);
+  }
+
+  // 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(undefined, 'is:open -age:90d')
+      .then(response => {
+        if (!response) return [];
+        const changes: RebaseChange[] = [];
+        for (const key in response) {
+          if (!hasOwnProperty(response, 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();
+  }
+
+  _getChangeSuggestions(input: string) {
+    return this._getRecentChanges().then(changes =>
+      this._filterChanges(input, changes)
+    );
+  }
+
+  _filterChanges(
+    input: string,
+    changes: RebaseChange[]
+  ): AutocompleteSuggestion[] {
+    return changes
+      .filter(
+        change =>
+          change.name.includes(input) && change.value !== this.changeNumber
+      )
+      .map(
+        change =>
+          ({
+            name: change.name,
+            value: `${change.value}`,
+          } as AutocompleteSuggestion)
+      );
+  }
+
+  _displayParentOption(rebaseOnCurrent: boolean, hasParent: boolean) {
+    return hasParent && rebaseOnCurrent;
+  }
+
+  _displayParentUpToDateMsg(rebaseOnCurrent: boolean, hasParent: boolean) {
+    return hasParent && !rebaseOnCurrent;
+  }
+
+  _displayTipOption(rebaseOnCurrent: boolean, hasParent: boolean) {
+    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 '';
+    }
+    if (!this._text) {
+      return '';
+    }
+    // Change numbers will have their description appended by the
+    // autocomplete.
+    return this._text.split(':')[0];
+  }
+
+  _handleConfirmTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    const detail: ConfirmRebaseEventDetail = {
+      base: this._getSelectedBase(),
+    };
+    this.dispatchEvent(new CustomEvent('confirm', {detail}));
+    this._text = '';
+  }
+
+  _handleCancelTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel'));
+    this._text = '';
+  }
+
+  _handleRebaseOnOther() {
+    this.$.parentInput.focus();
+  }
+
+  _handleEnterChangeNumberClick() {
+    this.$.rebaseOnOtherInput.checked = true;
+  }
+
+  /**
+   * Sets the default radio button based on the state of the app and
+   * the corresponding value to be submitted.
+   */
+  @observe('rebaseOnCurrent', 'hasParent')
+  _updateSelectedOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
+    // Polymer 2: check for undefined
+    if (rebaseOnCurrent === undefined || hasParent === 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;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-rebase-dialog': 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
deleted file mode 100644
index e9a8424..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
new file mode 100644
index 0000000..687d31f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 080a7e0..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
+++ /dev/null
@@ -1,204 +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">
-<meta charset="utf-8">
-<title>gr-confirm-rebase-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-rebase-dialog></gr-confirm-rebase-dialog>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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;
-      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-rebase-dialog/gr-confirm-rebase-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
new file mode 100644
index 0000000..8bce572
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
@@ -0,0 +1,184 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-rebase-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-rebase-dialog');
+
+suite('gr-confirm-rebase-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('controls with parent and rebase on current available', () => {
+    element.rebaseOnCurrent = true;
+    element.hasParent = true;
+    flush();
+    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;
+    flush();
+    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;
+    flush();
+    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;
+    flush();
+    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(() => {
+      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,
+        },
+      ];
+
+      sinon.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', () => {
+      sinon.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', () => {
+      sinon.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);
+    });
+  });
+});
+
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
deleted file mode 100644
index 6eb4c82..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@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 '../../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';
-
-const ERR_COMMIT_NOT_FOUND =
-    'Unable to find the commit hash of this change.';
-const CHANGE_SUBJECT_LIMIT = 50;
-
-// TODO(dhruvsri): clean up repeated definitions after moving to js modules
-const REVERT_TYPES = {
-  REVERT_SINGLE_CHANGE: 1,
-  REVERT_SUBMISSION: 2,
-};
-
-/**
- * @extends Polymer.Element
- */
-class GrConfirmRevertDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-confirm-revert-dialog'; }
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  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 []; },
-      },
-    };
-  }
-
-  _computeIfSingleRevert(revertType) {
-    return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
-  }
-
-  _computeIfRevertSubmission(revertType) {
-    return revertType === REVERT_TYPES.REVERT_SUBMISSION;
-  }
-
-  _modifyRevertMsg(change, commitMessage, message) {
-    return this.$.jsAPI.modifyRevertMsg(change,
-        message, commitMessage);
-  }
-
-  populate(change, commitMessage, changes) {
-    this._changesCount = changes.length;
-    // The option to revert a single change is always available
-    this._populateRevertSingleChangeMessage(
-        change, commitMessage, change.current_revision);
-    this._populateRevertSubmissionMessage(change, changes, commitMessage);
-  }
-
-  _populateRevertSingleChangeMessage(change, commitMessage, commitHash) {
-    // Figure out what the revert title should be.
-    const originalTitle = (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.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
new file mode 100644
index 0000000..5c0b19f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -0,0 +1,257 @@
+/**
+ * @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 '../../shared/gr-dialog/gr-dialog';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-revert-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {ChangeInfo, CommitId} from '../../../types/common';
+
+const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
+const CHANGE_SUBJECT_LIMIT = 50;
+
+// TODO(dhruvsri): clean up repeated definitions after moving to js modules
+export enum RevertType {
+  REVERT_SINGLE_CHANGE = 1,
+  REVERT_SUBMISSION = 2,
+}
+
+export interface ConfirmRevertEventDetail {
+  revertType: RevertType;
+  message?: string;
+}
+
+export interface GrConfirmRevertDialog {
+  $: {
+    jsAPI: JsApiService & Element;
+  };
+}
+@customElement('gr-confirm-revert-dialog')
+export class GrConfirmRevertDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  /* The revert message updated by the user
+      The default value is set by the dialog */
+  @property({type: String})
+  _message?: string;
+
+  @property({type: Number})
+  _revertType = RevertType.REVERT_SINGLE_CHANGE;
+
+  @property({type: Boolean})
+  _showRevertSubmission = false;
+
+  @property({type: Number})
+  _changesCount?: number;
+
+  @property({type: Boolean})
+  _showErrorMessage = 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 */
+  @property({type: Array})
+  _originalRevertMessages: string[] = [];
+
+  // Store the actual messages that the user has edited
+  @property({type: Array})
+  _revertMessages: string[] = [];
+
+  _computeIfSingleRevert(revertType: number) {
+    return revertType === RevertType.REVERT_SINGLE_CHANGE;
+  }
+
+  _computeIfRevertSubmission(revertType: number) {
+    return revertType === RevertType.REVERT_SUBMISSION;
+  }
+
+  _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+    return this.$.jsAPI.modifyRevertMsg(change, message, commitMessage);
+  }
+
+  populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
+    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: ChangeInfo,
+    commitMessage: string,
+    commitHash?: CommitId
+  ) {
+    // 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}.`;
+
+    const 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, message);
+    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
+    this._showRevertSubmission = false;
+    this._revertMessages[this._revertType] = this._message;
+    this._originalRevertMessages[this._revertType] = this._message;
+  }
+
+  _getTrimmedChangeSubject(subject: string) {
+    if (!subject) return '';
+    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+  }
+
+  _modifyRevertSubmissionMsg(
+    change: ChangeInfo,
+    msg: string,
+    commitMessage: string
+  ) {
+    return this.$.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
+  }
+
+  _populateRevertSubmissionMessage(
+    change: ChangeInfo,
+    changes: ChangeInfo[],
+    commitMessage: string
+  ) {
+    // 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 revertTitle = `Revert submission ${change.submission_id}`;
+    let message =
+      revertTitle +
+      '\n\n' +
+      'Reason for revert: <INSERT ' +
+      'REASONING HERE>\n';
+    message += 'Reverted Changes:\n';
+    changes.forEach(change => {
+      message +=
+        `${change.change_id.substring(0, 10)}:` +
+        `${this._getTrimmedChangeSubject(change.subject)}\n`;
+    });
+    this._message = this._modifyRevertSubmissionMsg(
+      change,
+      message,
+      commitMessage
+    );
+    this._revertType = RevertType.REVERT_SUBMISSION;
+    this._revertMessages[this._revertType] = this._message;
+    this._originalRevertMessages[this._revertType] = this._message;
+    this._showRevertSubmission = true;
+  }
+
+  _handleRevertSingleChangeClicked() {
+    this._showErrorMessage = false;
+    if (this._message)
+      this._revertMessages[RevertType.REVERT_SUBMISSION] = this._message;
+    this._message = this._revertMessages[RevertType.REVERT_SINGLE_CHANGE];
+    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
+  }
+
+  _handleRevertSubmissionClicked() {
+    this._showErrorMessage = false;
+    this._revertType = RevertType.REVERT_SUBMISSION;
+    if (this._message)
+      this._revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this._message;
+    this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
+  }
+
+  _handleConfirmTap(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    if (this._message === this._originalRevertMessages[this._revertType]) {
+      this._showErrorMessage = true;
+      return;
+    }
+    const detail: ConfirmRevertEventDetail = {
+      revertType: this._revertType,
+      message: this._message,
+    };
+    this.dispatchEvent(
+      new CustomEvent('confirm', {
+        detail,
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCancelTap(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        detail: {revertType: this._revertType},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-revert-dialog': 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
deleted file mode 100644
index 7875fa7..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
new file mode 100644
index 0000000..f5561fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 3a341c5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ /dev/null
@@ -1,101 +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">
-<meta charset="utf-8">
-<title>gr-confirm-revert-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-dialog></gr-confirm-revert-dialog>
-  </template>
-</test-fixture>
-
-<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._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-dialog/gr-confirm-revert-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js
new file mode 100644
index 0000000..7c84043
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-revert-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-revert-dialog');
+
+suite('gr-confirm-revert-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('no match', () => {
+    assert.isNotOk(element._message);
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    element._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);
+  });
+});
+
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
deleted file mode 100644
index 1437c76..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@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.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
new file mode 100644
index 0000000..9754e89
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
@@ -0,0 +1,140 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../shared/gr-dialog/gr-dialog';
+import '../../../styles/shared-styles';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-revert-submission-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {ChangeInfo} from '../../../types/common';
+
+const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
+const CHANGE_SUBJECT_LIMIT = 50;
+
+export interface GrConfirmRevertSubmissionDialog {
+  $: {
+    jsAPI: JsApiService & Element;
+  };
+}
+@customElement('gr-confirm-revert-submission-dialog')
+export class GrConfirmRevertSubmissionDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: String})
+  message?: string;
+
+  @property({type: String})
+  commitMessage?: string;
+
+  _getTrimmedChangeSubject(subject: string) {
+    if (!subject) return '';
+    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+  }
+
+  _modifyRevertSubmissionMsg(change?: ChangeInfo) {
+    if (!change || !this.message || !this.commitMessage) {
+      return this.message;
+    }
+    return this.$.jsAPI.modifyRevertSubmissionMsg(
+      change,
+      this.message,
+      this.commitMessage
+    );
+  }
+
+  _populateRevertSubmissionMessage(
+    change?: ChangeInfo,
+    changes?: ChangeInfo[]
+  ) {
+    if (change === undefined) {
+      return;
+    }
+    // Follow the same convention of the revert
+    const commitHash = change.current_revision;
+    if (!commitHash) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: ERR_COMMIT_NOT_FOUND},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    const revertTitle = `Revert submission ${change.submission_id}`;
+    this.message =
+      revertTitle + '\n\n' + 'Reason for revert: <INSERT REASONING HERE>\n';
+    changes = changes || [];
+    if (changes.length) {
+      this.message += 'Reverted Changes:\n';
+      changes.forEach(change => {
+        this.message +=
+          `${change.change_id.substring(0, 10)}: ` +
+          `${this._getTrimmedChangeSubject(change.subject)}\n`;
+      });
+    }
+    this.message = this._modifyRevertSubmissionMsg(change);
+  }
+
+  _handleConfirmTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCancelTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-revert-submission-dialog': GrConfirmRevertSubmissionDialog;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js
deleted file mode 100644
index 48051a0..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
new file mode 100644
index 0000000..cae4e1f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index a11d996..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
+++ /dev/null
@@ -1,99 +0,0 @@
-<!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-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
new file mode 100644
index 0000000..1ed799f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-revert-submission-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-revert-submission-dialog');
+
+suite('gr-confirm-revert-submission-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('no match', () => {
+    assert.isNotOk(element.message);
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    element._populateRevertSubmissionMessage(
+        'not a commitHash in sight', {}
+    );
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('single line', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('multi line', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('issue above change id', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('revert a revert', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
deleted file mode 100644
index 5d599b7..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
+++ /dev/null
@@ -1,94 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @extends Polymer.Element */
-class GrConfirmSubmitDialog extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-confirm-submit-dialog'; }
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  static get properties() {
-    return {
-      /**
-       * @type {Gerrit.Change}
-       */
-      change: Object,
-
-      /**
-       * @type {{
-       *    label: string,
-       *  }}
-       */
-      action: Object,
-    };
-  }
-
-  resetFocus(e) {
-    this.$.dialog.resetFocus();
-  }
-
-  _computeHasChangeEdit(change) {
-    return !!change.revisions &&
-        Object.values(change.revisions).some(rev => rev._number == 'edit');
-  }
-
-  _computeUnresolvedCommentsWarning(change) {
-    const unresolvedCount = change.unresolved_comment_count;
-    const plural = unresolvedCount > 1 ? 's' : '';
-    return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
new file mode 100644
index 0000000..666f95d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -0,0 +1,98 @@
+/**
+ * @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.
+ */
+import '@polymer/iron-icon/iron-icon';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-submit-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {ChangeInfo, ActionInfo} from '../../../types/common';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+
+export interface GrConfirmSubmitDialog {
+  $: {
+    dialog: GrDialog;
+  };
+}
+@customElement('gr-confirm-submit-dialog')
+export class GrConfirmSubmitDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  action?: ActionInfo;
+
+  resetFocus() {
+    this.$.dialog.resetFocus();
+  }
+
+  _computeHasChangeEdit(change?: ChangeInfo) {
+    return (
+      !!change &&
+      !!change.revisions &&
+      Object.values(change.revisions).some(rev => rev._number === 'edit')
+    );
+  }
+
+  _computeUnresolvedCommentsWarning(change: ChangeInfo) {
+    const unresolvedCount = change.unresolved_comment_count;
+    const plural = unresolvedCount && unresolvedCount > 1 ? 's' : '';
+    return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
+  }
+
+  _handleConfirmTap(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
+  }
+
+  _handleCancelTap(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-submit-dialog': 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
deleted file mode 100644
index cf1a332..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
new file mode 100644
index 0000000..84668ed
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.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.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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
deleted file mode 100644
index 30699d5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
+++ /dev/null
@@ -1,100 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-submit-dialog></gr-confirm-submit-dialog>
-  </template>
-</test-fixture>
-
-<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.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-confirm-submit-dialog/gr-confirm-submit-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
new file mode 100644
index 0000000..e16ffdb
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
@@ -0,0 +1,79 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-submit-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-submit-dialog');
+
+suite('gr-file-list-header tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('display', () => {
+    element.action = {label: 'my-label'};
+    element.change = {
+      subject: 'my-subject',
+      revisions: {},
+    };
+    flush();
+    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);
+  });
+});
+
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
deleted file mode 100644
index 4c457a1..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ /dev/null
@@ -1,235 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrDownloadDialog extends mixinBehaviors( [
-  PatchSetBehavior,
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  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 {?} */
-      config: Object,
-
-      _schemes: {
-        type: Array,
-        value() { return []; },
-        computed: '_computeSchemes(change, patchNum)',
-        observer: '_schemesChanged',
-      },
-      _selectedScheme: String,
-    };
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
-  }
-
-  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;
-  }
-
-  /**
-   * @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;
-      }
-    }
-    return false;
-  }
-
-  _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;
-  }
-
-  _computeSchemes(change, patchNum) {
-    // Polymer 2: check for undefined
-    if ([change, patchNum].some(arg => arg === undefined)) {
-      return [];
-    }
-
-    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 [];
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
new file mode 100644
index 0000000..27d9756
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -0,0 +1,254 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-download-commands/gr-download-commands';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-download-dialog_html';
+import {patchNumEquals} from '../../../utils/patch-set-util';
+import {changeBaseURL} from '../../../utils/change-util';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {ChangeInfo, ServerInfo, PatchSetNum} from '../../../types/common';
+import {RevisionInfo} from '../../shared/revision-info/revision-info';
+import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
+
+export interface GrDownloadDialog {
+  $: {
+    download: HTMLAnchorElement;
+    downloadCommands: GrDownloadCommands;
+    closeButton: GrButton;
+  };
+}
+
+@customElement('gr-download-dialog')
+export class GrDownloadDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the user presses the close button.
+   *
+   * @event close
+   */
+
+  @property({type: Object})
+  change: ChangeInfo | undefined;
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  @property({type: String})
+  patchNum: PatchSetNum | undefined;
+
+  @property({type: String})
+  _selectedScheme?: string;
+
+  @computed('change', 'patchNum')
+  get _schemes() {
+    // Polymer 2: check for undefined
+    if (this.change === undefined || this.patchNum === undefined) {
+      return [];
+    }
+
+    for (const rev of Object.values(this.change.revisions || {})) {
+      if (patchNumEquals(rev._number, this.patchNum)) {
+        const fetch = rev.fetch;
+        if (fetch) {
+          return Object.keys(fetch).sort();
+        }
+        break;
+      }
+    }
+    return [];
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
+
+  focus() {
+    if (this._schemes.length) {
+      this.$.downloadCommands.focusOnCopy();
+    } else {
+      this.$.download.focus();
+    }
+  }
+
+  getFocusStops(): GrOverlayStops {
+    return {
+      start: this.$.downloadCommands.$.downloadTabs,
+      end: this.$.closeButton,
+    };
+  }
+
+  _computeDownloadCommands(
+    change?: ChangeInfo,
+    patchNum?: PatchSetNum,
+    selectedScheme?: string
+  ) {
+    let commandObj;
+    if (!change || !selectedScheme) return [];
+    for (const rev of Object.values(change.revisions || {})) {
+      if (
+        patchNumEquals(rev._number, patchNum) &&
+        rev &&
+        rev.fetch &&
+        hasOwnProperty(rev.fetch, selectedScheme)
+      ) {
+        commandObj = rev.fetch[selectedScheme].commands;
+        break;
+      }
+    }
+    const commands = [];
+    for (const title in commandObj) {
+      if (!commandObj || !hasOwnProperty(commandObj, title)) {
+        continue;
+      }
+      commands.push({
+        title,
+        command: commandObj[title],
+      });
+    }
+    return commands;
+  }
+
+  _computeZipDownloadLink(change?: ChangeInfo, patchNum?: PatchSetNum) {
+    return this._computeDownloadLink(change, patchNum, true);
+  }
+
+  _computeZipDownloadFilename(change?: ChangeInfo, patchNum?: PatchSetNum) {
+    return this._computeDownloadFilename(change, patchNum, true);
+  }
+
+  _computeDownloadLink(
+    change?: ChangeInfo,
+    patchNum?: PatchSetNum,
+    zip?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (change === undefined || patchNum === undefined) {
+      return '';
+    }
+    return (
+      changeBaseURL(change.project, change._number, patchNum) +
+      '/patch?' +
+      (zip ? 'zip' : 'download')
+    );
+  }
+
+  _computeDownloadFilename(
+    change?: ChangeInfo,
+    patchNum?: PatchSetNum,
+    zip?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (change === undefined || patchNum === undefined) {
+      return '';
+    }
+
+    let shortRev = '';
+    for (const rev in change.revisions) {
+      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
+        shortRev = rev.substr(0, 7);
+        break;
+      }
+    }
+    return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
+  }
+
+  _computeHidePatchFile(change?: ChangeInfo, patchNum?: PatchSetNum) {
+    // Polymer 2: check for undefined
+    if (change === undefined || patchNum === undefined) {
+      return false;
+    }
+    for (const rev of Object.values(change.revisions || {})) {
+      if (patchNumEquals(rev._number, patchNum)) {
+        const parentLength =
+          rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
+        return parentLength === 0 || parentLength > 1;
+      }
+    }
+    return false;
+  }
+
+  _computeArchiveDownloadLink(
+    change?: ChangeInfo,
+    patchNum?: PatchSetNum,
+    format?: string
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      change === undefined ||
+      patchNum === undefined ||
+      format === undefined
+    ) {
+      return '';
+    }
+    return (
+      changeBaseURL(change.project, change._number, patchNum) +
+      '/archive?format=' +
+      format
+    );
+  }
+
+  _computePatchSetQuantity(revisions?: {[revisionId: string]: RevisionInfo}) {
+    if (!revisions) {
+      return 0;
+    }
+    return Object.keys(revisions).length;
+  }
+
+  _handleCloseTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('close', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  @observe('_schemes')
+  _schemesChanged(schemes: string[]) {
+    if (schemes.length === 0) {
+      return;
+    }
+    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
+      this._selectedScheme = schemes.sort()[0];
+    }
+  }
+
+  _computeShowDownloadCommands(schemes: string[]) {
+    return schemes.length ? '' : 'hidden';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-download-dialog': 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
deleted file mode 100644
index 0a118d6..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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;
-    }
-    gr-download-commands {
-      width: min(80vw, 1200px);
-    }
-  </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_html.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
new file mode 100644
index 0000000..9dc9832
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
@@ -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';
+
+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;
+    }
+    .hidden {
+      display: none;
+    }
+    gr-download-commands {
+      width: min(80vw, 1200px);
+    }
+  </style>
+  <section>
+    <h3 class="heading-3">
+      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
deleted file mode 100644
index 46c57fe..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ /dev/null
@@ -1,222 +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">
-<meta charset="utf-8">
-<title>gr-download-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-download-dialog></gr-download-dialog>
-  </template>
-</test-fixture>
-
-<test-fixture id="loggedIn">
-  <template>
-    <gr-download-dialog logged-in></gr-download-dialog>
-  </template>
-</test-fixture>
-
-<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',
-            },
-          },
-          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',
-            },
-          },
-        },
-      },
-    },
-  };
-}
-
-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'],
-    };
-
-    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(() => {
-      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.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-download-dialog/gr-download-dialog_test.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
new file mode 100644
index 0000000..7401026
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
@@ -0,0 +1,206 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-download-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-download-dialog');
+
+function getChangeObject() {
+  return {
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    revisions: {
+      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+        _number: 1,
+        commit: {
+          parents: [],
+        },
+        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',
+            },
+          },
+          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: {},
+      },
+    },
+  };
+}
+
+suite('gr-download-dialog', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.patchNum = '1';
+    element.config = {
+      schemes: {
+        'anonymous http': {},
+        'http': {},
+        'repo': {},
+        'ssh': {},
+      },
+      archives: ['tgz', 'tar', 'tbz2', 'txz'],
+    };
+
+    flush();
+  });
+
+  test('anchors use download attribute', () => {
+    const anchors = Array.from(
+        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();
+      flush();
+    });
+
+    test('focuses on first download link if no copy links', () => {
+      const focusStub = sinon.stub(element.$.download, 'focus');
+      element.focus();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
+    });
+  });
+
+  suite('gr-download-dialog with fetch options', () => {
+    setup(() => {
+      element.change = getChangeObject();
+      flush();
+    });
+
+    test('focuses on first copy link', () => {
+      const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
+      element.focus();
+      flush();
+      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 changeWithNoParent = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: []}},
+      },
+    };
+    assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
+
+    const changeWithOneParent = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: [
+          {commit: 'p1'},
+        ]}},
+      },
+    };
+    assert.isFalse(
+        element._computeHidePatchFile(changeWithOneParent, patchNum));
+
+    const changeWithMultipleParents = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: [
+          {commit: 'p1'},
+          {commit: 'p2'},
+        ]}},
+      },
+    };
+    assert.isTrue(
+        element._computeHidePatchFile(changeWithMultipleParents, patchNum));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.js b/polygerrit-ui/app/elements/change/gr-file-list-constants.js
deleted file mode 100644
index 5bba786..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-constants.js
+++ /dev/null
@@ -1,25 +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.
- */
-
-export const GrFileListConstants = {
-  FilesExpandedState: {
-    ALL: 'all',
-    NONE: 'none',
-    SOME: 'some',
-  },
-};
-
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
new file mode 100644
index 0000000..0e55494
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
@@ -0,0 +1,22 @@
+/**
+ * @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 enum FilesExpandedState {
+  ALL = 'all',
+  NONE = 'none',
+  SOME = 'some',
+}
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
deleted file mode 100644
index 73c6721..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ /dev/null
@@ -1,303 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
-const MERGED_STATUS = 'MERGED';
-
-/**
- * @extends Polymer.Element
- */
-class GrFileListHeader extends mixinBehaviors( [
-  PatchSetBehavior,
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-file-list-header'; }
-  /**
-   * @event expand-diffs
-   */
-
-  /**
-   * @event collapse-diffs
-   */
-
-  /**
-   * @event open-diff-prefs
-   */
-
-  /**
-   * @event open-included-in-dialog
-   */
-
-  /**
-   * @event open-download-dialog
-   */
-
-  /**
-   * @event open-upload-help-dialog
-   */
-
-  static get properties() {
-    return {
-      account: Object,
-      allPatchSets: Array,
-      /** @type {?} */
-      change: Object,
-      changeNum: String,
-      changeUrl: String,
-      changeComments: Object,
-      commitInfo: Object,
-      editMode: Boolean,
-      loggedIn: Boolean,
-      serverConfig: Object,
-      shownFileCount: Number,
-      diffPrefs: Object,
-      diffPrefsDisabled: Boolean,
-      diffViewMode: {
-        type: String,
-        notify: true,
-      },
-      patchNum: String,
-      basePatchNum: String,
-      filesExpanded: String,
-      // Caps the number of files that can be shown and have the 'show diffs' /
-      // 'hide diffs' buttons still be functional.
-      _maxFilesForBulkActions: {
-        type: Number,
-        readOnly: true,
-        value: 225,
-      },
-      _patchsetDescription: {
-        type: String,
-        value: '',
-      },
-      _descriptionReadOnly: {
-        type: Boolean,
-        computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
-      },
-      revisionInfo: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_computePatchSetDescription(change, patchNum)',
-    ];
-  }
-
-  setDiffViewMode(mode) {
-    this.$.modeSelect.setMode(mode);
-  }
-
-  _expandAllDiffs() {
-    this._expanded = true;
-    this.dispatchEvent(new CustomEvent('expand-diffs', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _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');
-    }
-    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;
-      }
-    }
-  }
-
-  _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 = 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 => {
-          if (target) { target.disabled = false; }
-          return;
-        });
-  }
-
-  _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
-    return diffPrefsDisabled || !prefs;
-  }
-
-  _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
-    return shownFileCount <= maxFilesForBulkActions;
-  }
-
-  _handlePatchChange(e) {
-    const {basePatchNum, patchNum} = e.detail;
-    if (this.patchNumEquals(basePatchNum, this.basePatchNum) &&
-        this.patchNumEquals(patchNum, this.patchNum)) { return; }
-    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
-  }
-
-  _handlePrefsTap(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('open-diff-prefs', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _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}));
-  }
-
-  _computeEditModeClass(editMode) {
-    return editMode ? 'editMode' : '';
-  }
-
-  _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';
-  }
-
-  _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' : '');
-  }
-}
-
-customElements.define(GrFileListHeader.is, GrFileListHeader);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
new file mode 100644
index 0000000..b86dd90
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -0,0 +1,402 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import '../../diff/gr-patch-range-select/gr-patch-range-select';
+import '../../edit/gr-edit-controls/gr-edit-controls';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-linked-chip/gr-linked-chip';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-commit-info/gr-commit-info';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-file-list-header_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  computeLatestPatchNum,
+  getRevisionByPatchNum,
+  patchNumEquals,
+  PatchSet,
+} from '../../../utils/patch-set-util';
+import {property, computed, observe, customElement} from '@polymer/decorators';
+import {
+  AccountInfo,
+  ChangeInfo,
+  PatchSetNum,
+  CommitInfo,
+  ServerInfo,
+  DiffPreferencesInfo,
+  RevisionInfo,
+  NumericChangeId,
+} from '../../../types/common';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {DiffViewMode} from '../../../constants/constants';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
+const MERGED_STATUS = 'MERGED';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-list-header': GrFileListHeader;
+  }
+}
+
+export interface GrFileListHeader {
+  $: {
+    modeSelect: GrDiffModeSelector;
+    restAPI: RestApiService & Element;
+    expandBtn: GrButton;
+    collapseBtn: GrButton;
+  };
+}
+
+@customElement('gr-file-list-header')
+export class GrFileListHeader extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * @event expand-diffs
+   */
+
+  /**
+   * @event collapse-diffs
+   */
+
+  /**
+   * @event open-diff-prefs
+   */
+
+  /**
+   * @event open-included-in-dialog
+   */
+
+  /**
+   * @event open-download-dialog
+   */
+
+  /**
+   * @event open-upload-help-dialog
+   */
+
+  @property({type: Object})
+  account: AccountInfo | undefined;
+
+  @property({type: Array})
+  allPatchSets?: PatchSet[];
+
+  @property({type: Object})
+  change: ChangeInfo | undefined;
+
+  @property({type: String})
+  changeNum?: NumericChangeId;
+
+  @property({type: String})
+  changeUrl?: string;
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
+  commitInfo?: CommitInfo;
+
+  @property({type: Boolean})
+  editMode?: boolean;
+
+  @property({type: Boolean})
+  loggedIn: boolean | undefined;
+
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: Number})
+  shownFileCount?: number;
+
+  @property({type: Object})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Boolean})
+  diffPrefsDisabled?: boolean;
+
+  @property({type: String, notify: true})
+  diffViewMode?: DiffViewMode;
+
+  @property({type: String})
+  patchNum?: PatchSetNum;
+
+  @property({type: String})
+  basePatchNum?: PatchSetNum;
+
+  @property({type: String})
+  filesExpanded?: FilesExpandedState;
+
+  // Caps the number of files that can be shown and have the 'show diffs' /
+  // 'hide diffs' buttons still be functional.
+  @property({type: Number})
+  readonly _maxFilesForBulkActions = 225;
+
+  @property({type: String})
+  _patchsetDescription = '';
+
+  @property({type: Object})
+  revisionInfo?: RevisionInfo;
+
+  @computed('loggedIn', 'change', 'account')
+  get _descriptionReadOnly(): boolean {
+    if (
+      this.loggedIn === undefined ||
+      this.change === undefined ||
+      this.account === undefined
+    ) {
+      return true;
+    }
+
+    return !(
+      this.loggedIn &&
+      this.account._account_id === this.change.owner._account_id
+    );
+  }
+
+  setDiffViewMode(mode: DiffViewMode) {
+    this.$.modeSelect.setMode(mode);
+  }
+
+  _expandAllDiffs() {
+    this.dispatchEvent(
+      new CustomEvent('expand-diffs', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _collapseAllDiffs() {
+    this.dispatchEvent(
+      new CustomEvent('collapse-diffs', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _computeExpandedClass(filesExpanded: FilesExpandedState) {
+    const classes = [];
+    if (filesExpanded === FilesExpandedState.ALL) {
+      classes.push('expanded');
+    }
+    if (
+      filesExpanded === FilesExpandedState.SOME ||
+      filesExpanded === FilesExpandedState.ALL
+    ) {
+      classes.push('openFile');
+    }
+    return classes.join(' ');
+  }
+
+  _computeDescriptionPlaceholder(readOnly: boolean) {
+    return (readOnly ? 'No' : 'Add') + ' patchset description';
+  }
+
+  @observe('change', 'patchNum')
+  _computePatchSetDescription(change: ChangeInfo, patchNum: PatchSetNum) {
+    // Polymer 2: check for undefined
+    if (
+      change === undefined ||
+      change.revisions === undefined ||
+      patchNum === undefined
+    ) {
+      return;
+    }
+
+    const rev = getRevisionByPatchNum(
+      Object.values(change.revisions),
+      patchNum
+    );
+    this._patchsetDescription = rev?.description
+      ? rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
+      : '';
+  }
+
+  _handleDescriptionRemoved(e: CustomEvent) {
+    return this._updateDescription('', e);
+  }
+
+  /**
+   * @param revisions The revisions object keyed by revision hashes
+   * @param patchSet A revision already fetched from {revisions}
+   * @return the SHA hash corresponding to the revision.
+   */
+  _getPatchsetHash(
+    revisions: {[revisionId: string]: RevisionInfo},
+    patchSet: RevisionInfo
+  ) {
+    for (const sha of Object.keys(revisions)) {
+      if (revisions[sha] === patchSet) {
+        return sha;
+      }
+    }
+    throw new Error('patchset hash not found');
+  }
+
+  _handleDescriptionChanged(e: CustomEvent) {
+    const desc = e.detail.trim();
+    this._updateDescription(desc, e);
+  }
+
+  /**
+   * Update the patchset description with the rest API.
+   */
+  _updateDescription(desc: string, e: CustomEvent) {
+    if (
+      !this.change ||
+      !this.change.revisions ||
+      !this.patchNum ||
+      !this.changeNum
+    )
+      return;
+    // target can be either gr-editable-label or gr-linked-chip
+    const target = (dom(e) as EventApi).rootTarget as HTMLElement & {
+      disabled: boolean;
+    };
+    if (target) {
+      target.disabled = true;
+    }
+    const rev = getRevisionByPatchNum(
+      Object.values(this.change.revisions),
+      this.patchNum
+    )!;
+    const sha = this._getPatchsetHash(this.change.revisions, rev);
+    return this.$.restAPI
+      .setDescription(this.changeNum, this.patchNum, desc)
+      .then((res: Response) => {
+        if (res.ok) {
+          if (target) {
+            target.disabled = false;
+          }
+          this.set(['change', 'revisions', sha, 'description'], desc);
+          this._patchsetDescription = desc;
+        }
+      })
+      .catch(() => {
+        if (target) {
+          target.disabled = false;
+        }
+        return;
+      });
+  }
+
+  _computePrefsButtonHidden(
+    prefs: DiffPreferencesInfo,
+    diffPrefsDisabled: boolean
+  ) {
+    return diffPrefsDisabled || !prefs;
+  }
+
+  _fileListActionsVisible(
+    shownFileCount: number,
+    maxFilesForBulkActions: number
+  ) {
+    return shownFileCount <= maxFilesForBulkActions;
+  }
+
+  _handlePatchChange(e: CustomEvent) {
+    const {basePatchNum, patchNum} = e.detail;
+    if (
+      (patchNumEquals(basePatchNum, this.basePatchNum) &&
+        patchNumEquals(patchNum, this.patchNum)) ||
+      !this.change
+    ) {
+      return;
+    }
+    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+  }
+
+  _handlePrefsTap(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('open-diff-prefs', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleIncludedInTap(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('open-included-in-dialog', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleDownloadTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('open-download-dialog', {bubbles: false})
+    );
+  }
+
+  _computeEditModeClass(editMode?: boolean) {
+    return editMode ? 'editMode' : '';
+  }
+
+  _computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
+    const latestNum = computeLatestPatchNum(allPatchSets);
+    if (patchNumEquals(patchNum, latestNum)) {
+      return '';
+    }
+    return 'patchInfoOldPatchSet';
+  }
+
+  _hideIncludedIn(change?: ChangeInfo) {
+    return change?.status === MERGED_STATUS ? '' : 'hide';
+  }
+
+  _handleUploadTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('open-upload-help-dialog', {bubbles: false})
+    );
+  }
+
+  _computeUploadHelpContainerClass(change: ChangeInfo, account: AccountInfo) {
+    const changeIsMerged = change?.status === MERGED_STATUS;
+    const ownerId = 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' : '');
+  }
+}
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
deleted file mode 100644
index 10b8606..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
+++ /dev/null
@@ -1,270 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
new file mode 100644
index 0000000..1355412
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -0,0 +1,277 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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)]]">
+      <template is="dom-if" if="[[editMode]]">
+        <span class="showOnEdit flexContainer">
+          <gr-edit-controls
+            id="editControls"
+            patch-num="[[patchNum]]"
+            change="[[change]]"
+          ></gr-edit-controls>
+          <span class="separator"></span>
+        </span>
+      </template>
+      <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.TOGGLE_ALL_INLINE_DIFFS,
+                ShortcutSection.FILE_LIST)]]"
+          on-click="_expandAllDiffs"
+          >Expand All</gr-button
+        >
+        <gr-button
+          id="collapseBtn"
+          link=""
+          on-click="_collapseAllDiffs"
+          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+          ShortcutSection.FILE_LIST)]]"
+          >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
deleted file mode 100644
index 19362d5..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ /dev/null
@@ -1,323 +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">
-<meta charset="utf-8">
-<title>gr-file-list-header</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/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-file-list-header></gr-file-list-header>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<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';
-
-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 = 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-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
new file mode 100644
index 0000000..3469b3a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
@@ -0,0 +1,318 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-file-list-header.js';
+import {FilesExpandedState} from '../gr-file-list-constants.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import 'lodash/lodash.js';
+import {createRevisions} from '../../../test/test-data-generators.js';
+
+const basicFixture = fixtureFromElement('gr-file-list-header');
+
+suite('gr-file-list-header tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve(null); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  teardown(done => {
+    flush(() => {
+      done();
+    });
+  });
+
+  test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
+    element.diffPrefsDisabled = true;
+    flush();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = false;
+    flush();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = true;
+    element.diffPrefs = {font_size: '12'};
+    flush();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = false;
+    flush();
+    assert.isFalse(element.$.diffPrefsContainer.hidden);
+  });
+
+  test('_computeDescriptionReadOnly', () => {
+    element.loggedIn = false;
+    element.change = {owner: {_account_id: 1}};
+    element.account = {_account_id: 1};
+    assert.equal(element._descriptionReadOnly, true);
+
+    element.loggedIn = true;
+    element.change = {owner: {_account_id: 0}};
+    element.account = {_account_id: 1};
+    assert.equal(element._descriptionReadOnly, true);
+
+    element.loggedIn = true;
+    element.change = {owner: {_account_id: 1}};
+    element.account = {_account_id: 1};
+    assert.equal(element._descriptionReadOnly, false);
+  });
+
+  test('_computeDescriptionPlaceholder', () => {
+    assert.equal(element._computeDescriptionPlaceholder(true),
+        'No patchset description');
+    assert.equal(element._computeDescriptionPlaceholder(false),
+        'Add patchset description');
+  });
+
+  test('description editing', () => {
+    const putDescStub = sinon.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.owner = {_account_id: 1};
+    element.loggedIn = true;
+
+    flush();
+
+    // The element has a description, so the account chip should be visible
+    element.owner = {_account_id: 1};
+    // and the description label should not exist.
+    const chip = element.root.querySelector('#descriptionChip');
+    let label = 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, '');
+
+          flush();
+          // The editable label should now be visible and the chip hidden.
+          label = 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();
+          flush();
+          // 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(() => {
+          flush();
+          // 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;
+    flush();
+    sinon.stub(element, '_expandAllDiffs');
+    MockInteractions.tap(element.root.querySelector(
+        '#expandBtn'));
+    assert.isTrue(element._expandAllDiffs.called);
+  });
+
+  test('collapseAllDiffs called when expand button clicked', () => {
+    element.shownFileCount = 1;
+    flush();
+    sinon.stub(element, '_collapseAllDiffs');
+    MockInteractions.tap(element.root.querySelector(
+        '#collapseBtn'));
+    assert.isTrue(element._collapseAllDiffs.called);
+  });
+
+  test('show/hide diffs disabled for large amounts of files', done => {
+    const computeSpy = sinon.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 = FilesExpandedState.SOME;
+    flush();
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = FilesExpandedState.ALL;
+    flush();
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = FilesExpandedState.NONE;
+    flush();
+    assert.equal(getComputedStyle(actions).display, 'none');
+  });
+
+  test('expand/collapse buttons are toggled correctly', () => {
+    element.shownFileCount = 10;
+    flush();
+    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 = FilesExpandedState.SOME;
+    flush();
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = FilesExpandedState.ALL;
+    flush();
+    assert.equal(getComputedStyle(expandBtn).display, 'none');
+    assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = FilesExpandedState.NONE;
+    flush();
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+  });
+
+  test('navigateToChange called when range select changes', () => {
+    const navigateToChangeStub = sinon.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;
+      element.allPatchSets = createRevisions(2);
+      flush();
+
+      assert.isFalse(isVisible(element.$.diffPrefsContainer));
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.descriptionContainer')));
+
+      element.editMode = false;
+      flush();
+
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.descriptionContainer')));
+      assert.isTrue(isVisible(element.$.diffPrefsContainer));
+    });
+
+    test('edit-controls visibility', () => {
+      element.editMode = false;
+      flush();
+      // on the first render, when editMode is false, editControls are not
+      // in the DOM to reduce size of DOM and make first render faster.
+      assert.isNull(element.shadowRoot
+          .querySelector('#editControls'));
+
+      element.editMode = true;
+      flush();
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('#editControls').parentElement));
+
+      element.editMode = false;
+      flush();
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('#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');
+    });
+  });
+});
+
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
deleted file mode 100644
index eb85cd7..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ /dev/null
@@ -1,1477 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
-const WARN_SHOW_ALL_THRESHOLD = 1000;
-const LOADING_DEBOUNCE_INTERVAL = 100;
-
-const SIZE_BAR_MAX_WIDTH = 61;
-const SIZE_BAR_GAP_WIDTH = 1;
-const SIZE_BAR_MIN_WIDTH = 1.5;
-
-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 FileStatus = {
-  A: 'Added',
-  C: 'Copied',
-  D: 'Deleted',
-  M: 'Modified',
-  R: 'Renamed',
-  W: 'Rewritten',
-  U: 'Unchanged',
-};
-
-/**
- * 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
- */
-
-/**
- * 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,
-      /** @type {?} */
-      changeComments: Object,
-      drafts: Object,
-      revisions: Array,
-      projectConfig: Object,
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      /** @type {?} */
-      change: Object,
-      diffViewMode: {
-        type: String,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      editMode: {
-        type: Boolean,
-        observer: '_editModeChanged',
-      },
-      filesExpanded: {
-        type: String,
-        value: GrFileListConstants.FilesExpandedState.NONE,
-        notify: true,
-      },
-      _filesByPath: Object,
-
-      /** @type {!Array<FileInfo>} */
-      _files: {
-        type: Array,
-        observer: '_filesChanged',
-        value() { return []; },
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _reviewed: {
-        type: Array,
-        value() { return []; },
-      },
-      diffPrefs: {
-        type: Object,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      /** @type {?} */
-      _userPrefs: Object,
-      _showInlineDiffs: Boolean,
-      numFilesShown: {
-        type: Number,
-        notify: true,
-      },
-      /** @type {?} */
-      _patchChange: {
-        type: Object,
-        computed: '_calculatePatchChange(_files)',
-      },
-      fileListIncrement: Number,
-      _hideChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideChangeTotals(_patchChange)',
-      },
-      _hideBinaryChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
-      },
-
-      _shownFiles: {
-        type: Array,
-        computed: '_computeFilesShown(numFilesShown, _files)',
-      },
-
-      /**
-       * The amount of files added to the shown files list the last time it was
-       * updated. This is used for reporting the average render time.
-       */
-      _reportinShownFilesIncrement: Number,
-
-      /** @type {!Array<FileData>} */
-      _expandedFiles: {
-        type: Array,
-        value() { return []; },
-      },
-      _displayLine: Boolean,
-      _loading: {
-        type: Boolean,
-        observer: '_loadingChanged',
-      },
-      /** @type {Gerrit.LayoutStats|undefined} */
-      _sizeBarLayout: {
-        type: Object,
-        computed: '_computeSizeBarLayout(_shownFiles.*)',
-      },
-
-      _showSizeBars: {
-        type: Boolean,
-        value: true,
-        computed: '_computeShowSizeBars(_userPrefs)',
-      },
-
-      /** @type {Function} */
-      _cancelForEachDiff: Function,
-
-      _showDynamicColumns: {
-        type: Boolean,
-        computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
-                '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
-      },
-      /** @type {Array<string>} */
-      _dynamicHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicContentEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicSummaryEndpoints: {
-        type: Array,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_expandedFilesChanged(_expandedFiles.splices)',
-      '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
-        '_loading)',
-    ];
-  }
-
-  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',
-
-      // Final two are actually handled by gr-comment-thread.
-      [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('keydown',
-        e => this._scopedKeydownHandler(e));
-  }
-
-  /** @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');
-
-      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 */
-  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();
-      }
-      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));
-      }
-      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._expandedFiles.some(f => f.path === 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 => Object.assign(
-        c, {__commentSide: threadEl.commentSide}
-    ));
-    flush();
-    return;
-  }
-
-  _handleEscKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this._displayLine = false;
-  }
-
-  /**
-   * Update the loading class for the file list rows. The update is inside a
-   * 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);
-  }
-
-  _editModeChanged(editMode) {
-    this.classList.toggle('editMode', editMode);
-  }
-
-  _computeReviewedClass(isReviewed) {
-    return isReviewed ? 'isReviewed' : '';
-  }
-
-  _computeReviewedText(isReviewed) {
-    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
-  }
-
-  /**
-   * Given a file path, return whether that path should have visible size bars
-   * 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;
-  }
-
-  /**
-   * 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 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 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 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 deletion bar for a file.
-   *
-   * @param {Gerrit.LayoutStats} stats
-   *
-   * @return {number}
-   */
-  _computeBarDeletionX(stats) {
-    return stats.deletionOffset;
-  }
-
-  _computeShowSizeBars(userPrefs) {
-    return !!userPrefs.size_bar_in_change_table;
-  }
-
-  _computeSizeBarsClass(showSizeBars, path) {
-    let hideClass = '';
-    if (!showSizeBars) {
-      hideClass = 'hide';
-    } else if (!this._showBarsForPath(path)) {
-      hideClass = 'invisible';
-    }
-    return `sizeBars desktop ${hideClass}`;
-  }
-
-  /**
-   * 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;
-  }
-
-  /**
-   * Returns true if none of the inline diffs have been expanded.
-   *
-   * @return {boolean}
-   */
-  _noDiffsExpanded() {
-    return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
-  }
-
-  /**
-   * 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 '';
-  }
-
-  _reviewedTitle(reviewed) {
-    if (reviewed) {
-      return 'Mark as not reviewed (shortcut: r)';
-    }
-
-    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.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
new file mode 100644
index 0000000..e188254
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -0,0 +1,1902 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../../diff/gr-diff-cursor/gr-diff-cursor';
+import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-file-list_html';
+import {asyncForeach} from '../../../utils/async-util';
+import {
+  KeyboardShortcutMixin,
+  Modifier,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {DiffViewMode, SpecialFilePath} from '../../../constants/constants';
+import {descendedFromClass} from '../../../utils/dom-util';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  computeTruncatedPath,
+  isMagicPath,
+  specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ConfigInfo,
+  DiffPreferencesInfo,
+  ElementPropertyDeepChange,
+  FileInfo,
+  FileNameToFileInfoMap,
+  NumericChangeId,
+  PatchRange,
+  PreferencesInfo,
+  RevisionInfo,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
+import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {UIDraft} from '../../../utils/comment-util';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {PatchSetFile} from '../../../types/types';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+export const DEFAULT_NUM_FILES_SHOWN = 200;
+
+const WARN_SHOW_ALL_THRESHOLD = 1000;
+const LOADING_DEBOUNCE_INTERVAL = 100;
+
+const SIZE_BAR_MAX_WIDTH = 61;
+const SIZE_BAR_GAP_WIDTH = 1;
+const SIZE_BAR_MIN_WIDTH = 1.5;
+
+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 FileStatus = {
+  A: 'Added',
+  C: 'Copied',
+  D: 'Deleted',
+  M: 'Modified',
+  R: 'Renamed',
+  W: 'Rewritten',
+  U: 'Unchanged',
+};
+
+const FILE_ROW_CLASS = 'file-row';
+
+export interface GrFileList {
+  $: {
+    restAPI: RestApiService & Element;
+    diffPreferencesDialog: GrDiffPreferencesDialog;
+    diffCursor: GrDiffCursor;
+    fileCursor: GrCursorManager;
+  };
+}
+
+interface ReviewedFileInfo extends FileInfo {
+  isReviewed?: boolean;
+}
+interface NormalizedFileInfo extends ReviewedFileInfo {
+  __path: string;
+}
+
+interface PatchChange {
+  inserted: number;
+  deleted: number;
+  size_delta_inserted: number;
+  size_delta_deleted: number;
+  total_size: number;
+}
+
+function createDefaultPatchChange(): PatchChange {
+  // Use function instead of const to prevent unexpected changes in the default
+  // values.
+  return {
+    inserted: 0,
+    deleted: 0,
+    size_delta_inserted: 0,
+    size_delta_deleted: 0,
+    total_size: 0,
+  };
+}
+
+interface SizeBarLayout {
+  maxInserted: number;
+  maxDeleted: number;
+  maxAdditionWidth: number;
+  maxDeletionWidth: number;
+  deletionOffset: number;
+}
+
+function createDefaultSizeBarLayout(): SizeBarLayout {
+  // Use function instead of const to prevent unexpected changes in the default
+  // values.
+  return {
+    maxInserted: 0,
+    maxDeleted: 0,
+    maxAdditionWidth: 0,
+    maxDeletionWidth: 0,
+    deletionOffset: 0,
+  };
+}
+
+interface FileRow {
+  file: PatchSetFile;
+  element: HTMLElement;
+}
+
+export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
+
+/**
+ * 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
+ */
+
+@customElement('gr-file-list')
+export class GrFileList extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when a draft refresh should get triggered
+   *
+   * @event reload-drafts
+   */
+
+  @property({type: Object})
+  patchRange?: PatchRange;
+
+  @property({type: String})
+  patchNum?: string;
+
+  @property({type: Number})
+  changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
+  drafts?: {[path: string]: UIDraft[]};
+
+  @property({type: Array})
+  revisions?: {[revisionId: string]: RevisionInfo};
+
+  @property({type: Object})
+  projectConfig?: ConfigInfo;
+
+  @property({type: Number, notify: true})
+  selectedIndex = -1;
+
+  @property({type: Object})
+  keyEventTarget = document.body;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: String, notify: true, observer: '_updateDiffPreferences'})
+  diffViewMode?: DiffViewMode;
+
+  @property({type: Boolean, observer: '_editModeChanged'})
+  editMode?: boolean;
+
+  @property({type: String, notify: true})
+  filesExpanded = FilesExpandedState.NONE;
+
+  @property({type: Object})
+  _filesByPath?: FileNameToFileInfoMap;
+
+  @property({type: Array, observer: '_filesChanged'})
+  _files: NormalizedFileInfo[] = [];
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Array})
+  _reviewed?: string[] = [];
+
+  @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  _userPrefs?: PreferencesInfo;
+
+  @property({type: Boolean})
+  _showInlineDiffs?: boolean;
+
+  @property({type: Number, notify: true})
+  numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Object, computed: '_calculatePatchChange(_files)'})
+  _patchChange: PatchChange = createDefaultPatchChange();
+
+  @property({type: Number})
+  fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'})
+  _hideChangeTotals = true;
+
+  @property({
+    type: Boolean,
+    computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+  })
+  _hideBinaryChangeTotals = true;
+
+  @property({
+    type: Array,
+    computed: '_computeFilesShown(numFilesShown, _files)',
+  })
+  _shownFiles: NormalizedFileInfo[] = [];
+
+  @property({type: Number})
+  _reportinShownFilesIncrement = 0;
+
+  @property({type: Array})
+  _expandedFiles: PatchSetFile[] = [];
+
+  @property({type: Boolean})
+  _displayLine?: boolean;
+
+  @property({type: Boolean, observer: '_loadingChanged'})
+  _loading?: boolean;
+
+  @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
+  _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
+
+  @property({type: Boolean, computed: '_computeShowSizeBars(_userPrefs)'})
+  _showSizeBars = true;
+
+  private _cancelForEachDiff?: () => void;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
+      '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
+  })
+  _showDynamicColumns = false;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeShowPrependedDynamicColumns(' +
+      '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
+  })
+  _showPrependedDynamicColumns = false;
+
+  @property({type: Array})
+  _dynamicHeaderEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicContentEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicSummaryEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicPrependedHeaderEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicPrependedContentEndpoints?: string[];
+
+  private readonly reporting = appContext.reportingService;
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+    };
+  }
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+        '_handleToggleHideAllCommentThreads',
+      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+      [Shortcut.NEXT_LINE]: '_handleCursorNext',
+      [Shortcut.PREV_LINE]: '_handleCursorPrev',
+      [Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+      [Shortcut.OPEN_FILE]: '_handleOpenFile',
+      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+
+      // Final two are actually handled by gr-comment-thread.
+      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-header'
+        );
+        this._dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content'
+        );
+        this._dynamicPrependedHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-header-prepend'
+        );
+        this._dynamicPrependedContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content-prepend'
+        );
+        this._dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-summary'
+        );
+
+        if (
+          this._dynamicHeaderEndpoints.length !==
+          this._dynamicContentEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list header and content.'
+          );
+        }
+        if (
+          this._dynamicPrependedHeaderEndpoints.length !==
+          this._dynamicPrependedContentEndpoints.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 */
+  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: KeyboardEvent) {
+    if (e.keyCode === 13) {
+      // TODO(TS): e is not an instance of CustomKeyboardEvent.
+      // However, to fix it we should fix keyboard-shortcut-mixin first
+      // The keyboard-shortcut-mixin will be updated in a separate change
+      this._handleOpenFile((e as unknown) as CustomKeyboardEvent);
+    }
+  }
+
+  reload() {
+    if (!this.changeNum || !this.patchRange?.patchNum) {
+      return Promise.resolve();
+    }
+    const changeNum = this.changeNum;
+    const patchRange = this.patchRange;
+
+    this._loading = true;
+
+    this.collapseAllDiffs();
+    const promises = [];
+
+    promises.push(
+      this.$.restAPI
+        .getChangeOrEditFiles(changeNum, patchRange)
+        .then(filesByPath => {
+          this._filesByPath = filesByPath;
+        })
+    );
+    promises.push(
+      this._getLoggedIn()
+        .then(loggedIn => (this._loggedIn = loggedIn))
+        .then(loggedIn => {
+          if (!loggedIn) {
+            return;
+          }
+
+          return this._getReviewedFiles(changeNum, patchRange).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(): GrDiffHost[] {
+    const diffs = 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: NormalizedFileInfo[]): PatchChange {
+    const magicFilesExcluded = files.filter(
+      files => !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,
+      };
+    }, createDefaultPatchChange());
+  }
+
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences();
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  private _toggleFileExpanded(file: PatchSetFile) {
+    // 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: number) {
+    this._toggleFileExpanded(this._computePatchSetFile(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: (host: GrDiffHost) => void) {
+    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: PatchSetFile[] = [];
+    let path: string;
+    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._computePatchSetFile(this._shownFiles[i]));
+      }
+    }
+
+    this.splice('_expandedFiles', 0, 0, ...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.
+   */
+  _computeCommentsString(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const unresolvedCount =
+      changeComments.computeUnresolvedNum({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeUnresolvedNum({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    const commentThreadCount =
+      changeComments.computeCommentThreadCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeCommentThreadCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    const commentString = GrCountStringFormatter.computePluralString(
+      commentThreadCount,
+      '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.
+   */
+  _computeDraftsString(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    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.
+   */
+  _computeDraftsStringMobile(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    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.
+   */
+  _computeCommentsStringMobile(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const commentThreadCount =
+      changeComments.computeCommentThreadCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeCommentThreadCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computeShortString(commentThreadCount, 'c');
+  }
+
+  private _reviewFile(path: string, reviewed?: boolean) {
+    if (this.editMode) {
+      return Promise.resolve();
+    }
+    const index = this._files.findIndex(file => file.__path === path);
+    reviewed = reviewed || !this._files[index].isReviewed;
+
+    this.set(['_files', index, 'isReviewed'], reviewed);
+    if (index < this._shownFiles.length) {
+      this.notifyPath(`_shownFiles.${index}.isReviewed`);
+    }
+
+    return this._saveReviewedState(path, reviewed);
+  }
+
+  _saveReviewedState(path: string, reviewed: boolean) {
+    if (!this.changeNum || !this.patchRange) {
+      throw new Error('changeNum and patchRange must be set');
+    }
+
+    return this.$.restAPI.saveFileReviewed(
+      this.changeNum,
+      this.patchRange.patchNum,
+      path,
+      reviewed
+    );
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+    if (this.editMode) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI.getReviewedFiles(changeNum, patchRange.patchNum);
+  }
+
+  _normalizeChangeFilesResponse(
+    response: FileNameToReviewedFileInfoMap
+  ): NormalizedFileInfo[] {
+    const paths = Object.keys(response).sort(specialFilePathCompare);
+    const files: NormalizedFileInfo[] = [];
+    for (let i = 0; i < paths.length; i++) {
+      // TODO(TS): make copy instead of as NormalizedFileInfo
+      const info = response[paths[i]] as NormalizedFileInfo;
+      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;
+  }
+
+  /**
+   * Returns true if the event e is a click on an element.
+   *
+   * The click is: mouse click or pressing Enter or Space key
+   * P.S> Screen readers sends click event as well
+   */
+  _isClickEvent(e: MouseEvent | KeyboardEvent) {
+    if (e.type === 'click') {
+      return true;
+    }
+    const ke = e as KeyboardEvent;
+    const isSpaceOrEnter = ke.key === 'Enter' || ke.key === ' ';
+    return ke.type === 'keydown' && isSpaceOrEnter;
+  }
+
+  _fileActionClick(
+    e: MouseEvent | KeyboardEvent,
+    fileAction: (file: PatchSetFile) => void
+  ) {
+    if (this._isClickEvent(e)) {
+      const fileRow = this._getFileRowFromEvent(e);
+      if (!fileRow) {
+        return;
+      }
+      // Prevent default actions (e.g. scrolling for space key)
+      e.preventDefault();
+      // Prevent _handleFileListClick handler call
+      e.stopPropagation();
+      this.$.fileCursor.setCursor(fileRow.element);
+      fileAction(fileRow.file);
+    }
+  }
+
+  _reviewedClick(e: MouseEvent | KeyboardEvent) {
+    this._fileActionClick(e, file => this._reviewFile(file.path));
+  }
+
+  _expandedClick(e: MouseEvent | KeyboardEvent) {
+    this._fileActionClick(e, file => this._toggleFileExpanded(file));
+  }
+
+  /**
+   * 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: MouseEvent) {
+    if (!e.target) {
+      return;
+    }
+    const fileRow = this._getFileRowFromEvent(e);
+    if (!fileRow) {
+      return;
+    }
+    const file = fileRow.file;
+    const path = file.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 || descendedFromClass(e.target as Element, 'pathLink')) {
+      return;
+    }
+
+    // Disregard the event if the click target is in the edit controls.
+    if (descendedFromClass(e.target as Element, 'editFileControls')) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.fileCursor.setCursor(fileRow.element);
+    this._toggleFileExpanded(file);
+  }
+
+  _getFileRowFromEvent(e: Event): FileRow | null {
+    // Traverse upwards to find the row element if the target is not the row.
+    let row = e.target as HTMLElement;
+    while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
+      row = row.parentElement;
+    }
+
+    // No action needed for item without a valid file
+    if (!row.dataset['file']) {
+      return null;
+    }
+
+    return {
+      file: JSON.parse(row.dataset['file']) as PatchSetFile,
+      element: row,
+    };
+  }
+
+  /**
+   * Generates file range from file info object.
+   */
+  _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
+    const fileData: PatchSetFile = {
+      path: file.__path,
+    };
+    if (file.old_path) {
+      fileData.basePath = file.old_path;
+    }
+    return fileData;
+  }
+
+  _handleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveLeft();
+  }
+
+  _handleRightPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveRight();
+  }
+
+  _handleToggleInlineDiff(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      this.modifierPressed(e) ||
+      this.$.fileCursor.index === -1
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleFileExpandedByIndex(this.$.fileCursor.index);
+  }
+
+  _handleToggleAllInlineDiffs(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleInlineDiffs();
+  }
+
+  _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.toggleClass('hideComments');
+  }
+
+  _handleCursorNext(e: CustomKeyboardEvent) {
+    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: CustomKeyboardEvent) {
+    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: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this.$.diffCursor.createCommentInPlace();
+  }
+
+  _handleOpenLastFile(e: CustomKeyboardEvent) {
+    // 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: CustomKeyboardEvent) {
+    // 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: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+
+    if (this._showInlineDiffs) {
+      this._openCursorFile();
+      return;
+    }
+
+    this._openSelectedFile();
+  }
+
+  _handleNextChunk(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      this._noDiffsExpanded()
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+      this.$.diffCursor.moveToNextCommentThread();
+    } else {
+      this.$.diffCursor.moveToNextChunk();
+    }
+  }
+
+  _handlePrevChunk(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      this._noDiffsExpanded()
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+      this.$.diffCursor.moveToPreviousCommentThread();
+    } else {
+      this.$.diffCursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+    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: CustomKeyboardEvent) {
+    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();
+    if (
+      !this.change ||
+      !diff ||
+      !this.patchRange ||
+      !diff.path ||
+      !diff.patchRange
+    ) {
+      throw new Error('change, diff and patchRange must be all set and valid');
+    }
+    GerritNav.navigateToDiff(
+      this.change,
+      diff.path,
+      diff.patchRange.patchNum,
+      this.patchRange.basePatchNum
+    );
+  }
+
+  _openSelectedFile(index?: number) {
+    if (index !== undefined) {
+      this.$.fileCursor.setCursorAtIndex(index);
+    }
+    if (!this._files[this.$.fileCursor.index]) {
+      return;
+    }
+    if (!this.change || !this.patchRange) {
+      throw new Error('change and patchRange must be set');
+    }
+    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: PatchChange): boolean {
+    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+  }
+
+  _shouldHideBinaryChangeTotals(_patchChange: PatchChange) {
+    return (
+      _patchChange.size_delta_inserted === 0 &&
+      _patchChange.size_delta_deleted === 0
+    );
+  }
+
+  _computeFileStatus(
+    status?: keyof typeof FileStatus
+  ): keyof typeof FileStatus {
+    return status || 'M';
+  }
+
+  _computeDiffURL(
+    change?: ParsedChangeInfo,
+    patchRange?: PatchRange,
+    path?: string,
+    editMode?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      change === undefined ||
+      patchRange === undefined ||
+      path === undefined ||
+      editMode === undefined
+    ) {
+      return;
+    }
+    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
+      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum);
+    }
+    return GerritNav.getUrlForDiff(
+      change,
+      path,
+      patchRange.patchNum,
+      patchRange.basePatchNum
+    );
+  }
+
+  _formatBytes(bytes?: number) {
+    if (!bytes) 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 ? '+' : '';
+    const value = parseFloat(
+      (bytes / Math.pow(bits, exponent)).toFixed(decimals)
+    );
+    return `${prepend}${value} ${sizes[exponent]}`;
+  }
+
+  _formatPercentage(size?: number, delta?: number) {
+    if (size === undefined || delta === undefined) {
+      return '';
+    }
+    const oldSize = size - delta;
+
+    if (oldSize === 0) {
+      return '';
+    }
+
+    const percentage = Math.round(Math.abs((delta * 100) / oldSize));
+    return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
+  }
+
+  _computeBinaryClass(delta?: number) {
+    if (!delta) {
+      return;
+    }
+    return delta > 0 ? 'added' : 'removed';
+  }
+
+  _computeClass(baseClass?: string, path?: string) {
+    const classes = [];
+    if (baseClass) {
+      classes.push(baseClass);
+    }
+    if (
+      path === SpecialFilePath.COMMIT_MESSAGE ||
+      path === SpecialFilePath.MERGE_LIST
+    ) {
+      classes.push('invisible');
+    }
+    return classes.join(' ');
+  }
+
+  _computeStatusClass(file?: NormalizedFileInfo) {
+    if (!file) return '';
+    const classStr = this._computeClass('status', file.__path);
+    return `${classStr} ${this._computeFileStatus(file.status)}`;
+  }
+
+  _computePathClass(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+  }
+
+  _computeShowHideIcon(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._isFileExpanded(path, expandedFilesRecord)
+      ? 'gr-icons:expand-less'
+      : 'gr-icons:expand-more';
+  }
+
+  @observe(
+    '_filesByPath',
+    'changeComments',
+    'patchRange',
+    '_reviewed',
+    '_loading'
+  )
+  _computeFiles(
+    filesByPath?: FileNameToFileInfoMap,
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    reviewed?: string[],
+    loading?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      filesByPath === undefined ||
+      changeComments === undefined ||
+      patchRange === undefined ||
+      reviewed === undefined ||
+      loading === undefined
+    ) {
+      return;
+    }
+
+    // Await all promises resolving from reload. @See Issue 9057
+    if (loading || !changeComments) {
+      return;
+    }
+
+    const commentedPaths = changeComments.getPaths(patchRange);
+    const files: FileNameToReviewedFileInfoMap = {...filesByPath};
+    addUnmodifiedFiles(files, commentedPaths);
+    const reviewedSet = new Set(reviewed || []);
+    for (const filePath in files) {
+      if (!hasOwnProperty(files, filePath)) {
+        continue;
+      }
+      files[filePath].isReviewed = reviewedSet.has(filePath);
+    }
+
+    this._files = this._normalizeChangeFilesResponse(files);
+  }
+
+  _computeFilesShown(
+    numFilesShown: number,
+    files: NormalizedFileInfo[]
+  ): NormalizedFileInfo[] | undefined {
+    // Polymer 2: check for undefined
+    if (numFilesShown === undefined || files === 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,
+      ...this.diffs
+    );
+  }
+
+  _filesChanged() {
+    if (this._files && this._files.length > 0) {
+      flush();
+      this.$.fileCursor.stops = Array.from(
+        this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
+      );
+      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+    }
+  }
+
+  _incrementNumFilesShown() {
+    this.numFilesShown += this.fileListIncrement;
+  }
+
+  _computeFileListControlClass(
+    numFilesShown?: number,
+    files?: NormalizedFileInfo[]
+  ) {
+    if (numFilesShown === undefined || files === undefined) return 'invisible';
+    return numFilesShown >= files.length ? 'invisible' : '';
+  }
+
+  _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) {
+    if (numFilesShown === undefined || files === undefined) return '';
+    const text = Math.min(this.fileListIncrement, files.length - numFilesShown);
+    return `Show ${text} more`;
+  }
+
+  _computeShowAllText(files: NormalizedFileInfo[]) {
+    if (!files) {
+      return '';
+    }
+    return `Show all ${files.length} files`;
+  }
+
+  _computeWarnShowAll(files: NormalizedFileInfo[]) {
+    return files.length > WARN_SHOW_ALL_THRESHOLD;
+  }
+
+  _computeShowAllWarning(files: NormalizedFileInfo[]) {
+    if (!this._computeWarnShowAll(files)) {
+      return '';
+    }
+    return `Warning: showing all ${files.length} files may take several seconds.`;
+  }
+
+  _showAllFiles() {
+    this.numFilesShown = this._files.length;
+  }
+
+  /**
+   * Get a descriptive label for use in the status indicator's tooltip and
+   * ARIA label.
+   */
+  _computeFileStatusLabel(status?: keyof typeof FileStatus) {
+    const statusCode = this._computeFileStatus(status);
+    return hasOwnProperty(FileStatus, statusCode)
+      ? FileStatus[statusCode]
+      : 'Status Unknown';
+  }
+
+  /**
+   * Converts any boolean-like variable to the string 'true' or 'false'
+   *
+   * This method is useful when you bind aria-checked attribute to a boolean
+   * value. The aria-checked attribute is string attribute. Binding directly
+   * to boolean variable causes problem on gerrit-CI.
+   *
+   * @return 'true' if val is true-like, otherwise false
+   */
+  _booleanToString(val?: unknown) {
+    return val ? 'true' : 'false';
+  }
+
+  _isFileExpanded(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return expandedFilesRecord.base.some(f => f.path === path);
+  }
+
+  _isFileExpandedStr(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._booleanToString(
+      this._isFileExpanded(path, expandedFilesRecord)
+    );
+  }
+
+  private _computeExpandedFiles(
+    expandedCount: number,
+    totalCount: number
+  ): FilesExpandedState {
+    if (expandedCount === 0) {
+      return FilesExpandedState.NONE;
+    } else if (expandedCount === totalCount) {
+      return FilesExpandedState.ALL;
+    }
+    return 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 record The splice record in the expanded paths list.
+   */
+  @observe('_expandedFiles.splices')
+  _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) {
+    // 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.reInitAndUpdateStops();
+  }
+
+  private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+    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
+   * continuing.
+   *
+   * @param initialCount The total number of paths in the pass. This
+   * is used to generate log messages.
+   */
+  private _renderInOrder(
+    files: PatchSetFile[],
+    diffElements: GrDiffHost[],
+    initialCount: number
+  ) {
+    let iter = 0;
+
+    for (const file of files) {
+      const path = file.path;
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (diffElem) {
+        diffElem.prefetchDiff();
+      }
+    }
+
+    return new Promise(resolve => {
+      this.dispatchEvent(
+        new CustomEvent('reload-drafts', {
+          detail: {resolve},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }).then(() =>
+      asyncForeach(files, (file, cancel) => {
+        const path = file.path;
+        this._cancelForEachDiff = cancel;
+
+        iter++;
+        console.info('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();
+        }
+        if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
+          throw new Error(
+            'changeComments, patchRange and diffPrefs must be set'
+          );
+        }
+        diffElem.comments = this.changeComments.getCommentsBySideForFile(
+          file,
+          this.patchRange,
+          this.projectConfig
+        );
+        const promises: Array<Promise<unknown>> = [diffElem.reload()];
+        if (this._loggedIn && !this.diffPrefs.manual_review) {
+          promises.push(this._reviewFile(path, true));
+        }
+        return Promise.all(promises);
+      }).then(() => {
+        this._cancelForEachDiff = undefined;
+        console.info('Finished expanding', initialCount, 'diff(s)');
+        this.reporting.timeEndWithAverage(
+          EXPAND_ALL_TIMING_LABEL,
+          EXPAND_ALL_AVG_TIMING_LABEL,
+          initialCount
+        );
+        /* Block diff cursor from auto scrolling after files are done rendering.
+       * This prevents the bug where the screen jumps to the first diff chunk
+       * after files are done being rendered after the user has already begun
+       * scrolling.
+       * This also however results in the fact that the cursor does not auto
+       * focus on the first diff chunk on a small screen. This is however, a use
+       * case we are willing to not support for now.
+
+       * Using handleDiffUpdate resulted in diffCursor.row being set which
+       * prevented the issue of scrolling to top when we expand the second
+       * file individually.
+       */
+        this.$.diffCursor.reInitAndUpdateStops();
+      })
+    );
+  }
+
+  /** 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.
+   */
+  private _findDiffByPath(path: string, diffElements: GrDiffHost[]) {
+    for (let i = 0; i < diffElements.length; i++) {
+      if (diffElements[i].path === path) {
+        return diffElements[i];
+      }
+    }
+    return undefined;
+  }
+
+  /**
+   * Reset the comments of a modified thread
+   */
+  reloadCommentsForThreadWithRootId(rootId: UrlEncodedCommentId, path: string) {
+    // 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);
+
+    if (!diff) {
+      throw new Error("Can't find diff by path");
+    }
+
+    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
+    if (!threadEl) {
+      return;
+    }
+
+    if (!this.changeComments) {
+      throw new Error('changeComments must be set');
+    }
+
+    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 =>
+      Object.assign(c, {__commentSide: threadEl.commentSide})
+    );
+    flush();
+  }
+
+  _handleEscKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this._displayLine = false;
+  }
+
+  /**
+   * Update the loading class for the file list rows. The update is inside a
+   * debouncer so that the file list doesn't flash gray when the API requests
+   * are reasonably fast.
+   */
+  _loadingChanged(loading?: boolean) {
+    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
+    );
+  }
+
+  _editModeChanged(editMode?: boolean) {
+    this.classList.toggle('editMode', editMode);
+  }
+
+  _computeReviewedClass(isReviewed?: boolean) {
+    return isReviewed ? 'isReviewed' : '';
+  }
+
+  _computeReviewedText(isReviewed?: boolean) {
+    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+  }
+
+  /**
+   * Given a file path, return whether that path should have visible size bars
+   * and be included in the size bars calculation.
+   */
+  _showBarsForPath(path?: string) {
+    return (
+      path !== SpecialFilePath.COMMIT_MESSAGE &&
+      path !== SpecialFilePath.MERGE_LIST
+    );
+  }
+
+  /**
+   * Compute size bar layout values from the file list.
+   */
+  _computeSizeBarLayout(
+    shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'>
+  ) {
+    const stats: SizeBarLayout = createDefaultSizeBarLayout();
+    if (!shownFilesRecord || !shownFilesRecord.base) {
+      return stats;
+    }
+    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 width of the addition bar for a file.
+   */
+  _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (
+      !file ||
+      !stats ||
+      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 x-offset of the addition bar for a file.
+   */
+  _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (!file || !stats) return;
+    return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats);
+  }
+
+  /**
+   * Get the width of the deletion bar for a file.
+   */
+  _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (
+      !file ||
+      !stats ||
+      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 deletion bar for a file.
+   */
+  _computeBarDeletionX(stats: SizeBarLayout) {
+    return stats.deletionOffset;
+  }
+
+  _computeShowSizeBars(userPrefs?: PreferencesInfo) {
+    return !!userPrefs?.size_bar_in_change_table;
+  }
+
+  _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
+    let hideClass = '';
+    if (!showSizeBars) {
+      hideClass = 'hide';
+    } else if (!this._showBarsForPath(path)) {
+      hideClass = 'invisible';
+    }
+    return `sizeBars desktop ${hideClass}`;
+  }
+
+  /**
+   * Shows registered dynamic columns iff the 'header', 'content' and
+   * 'summary' endpoints are registered 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?: string,
+    contentEndpoints?: string,
+    summaryEndpoints?: string
+  ) {
+    return (
+      headerEndpoints &&
+      contentEndpoints &&
+      summaryEndpoints &&
+      headerEndpoints.length &&
+      headerEndpoints.length === contentEndpoints.length &&
+      headerEndpoints.length === summaryEndpoints.length
+    );
+  }
+
+  /**
+   * Shows registered dynamic prepended columns iff the 'header', 'content'
+   * endpoints are registered the exact same number of times.
+   */
+  _computeShowPrependedDynamicColumns(
+    headerEndpoints?: string,
+    contentEndpoints?: string
+  ) {
+    return (
+      headerEndpoints &&
+      contentEndpoints &&
+      headerEndpoints.length &&
+      headerEndpoints.length === contentEndpoints.length
+    );
+  }
+
+  /**
+   * Returns true if none of the inline diffs have been expanded.
+   */
+  _noDiffsExpanded() {
+    return this.filesExpanded === FilesExpandedState.NONE;
+  }
+
+  /**
+   * 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 index The index of the row being rendered.
+   */
+  _reportRenderedRow(index: number) {
+    if (index === this._shownFiles.length - 1) {
+      this.async(() => {
+        this.reporting.timeEndWithAverage(
+          RENDER_TIMING_LABEL,
+          RENDER_AVG_TIMING_LABEL,
+          this._reportinShownFilesIncrement
+        );
+      }, 1);
+    }
+    return '';
+  }
+
+  _reviewedTitle(reviewed?: boolean) {
+    if (reviewed) {
+      return 'Mark as not reviewed (shortcut: r)';
+    }
+
+    return 'Mark as reviewed (shortcut: r)';
+  }
+
+  _handleReloadingDiffPreference() {
+    this._getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    });
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeDisplayPath(path: string) {
+    return computeDisplayPath(path);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeTruncatedPath(path: string) {
+    return computeTruncatedPath(path);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-list': 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
deleted file mode 100644
index a9a785e..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
+++ /dev/null
@@ -1,591 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
new file mode 100644
index 0000000..d93ce68
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -0,0 +1,775 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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);
+    }
+    /* The class defines a content visible only to screen readers */
+    .noCommentsScreenReaderText {
+      opacity: 0;
+      max-width: 1px;
+      overflow: hidden;
+      display: none;
+    }
+    div[role='gridcell']
+      > div.comments
+      > span:empty
+      + span:empty
+      + span.noCommentsScreenReaderText {
+      display: inline;
+    }
+    :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 {
+      display: inline-block;
+      text-align: left;
+      width: 1.5em;
+    }
+    .status {
+      display: inline-block;
+      border-radius: var(--border-radius);
+      margin-left: var(--spacing-s);
+      padding: 0 var(--spacing-m);
+      color: var(--primary-text-color);
+      font-size: var(--font-size-small);
+      background-color: var(--dark-add-highlight-color);
+    }
+    .status.invisible,
+    .status.M {
+      display: none;
+    }
+    .status.D,
+    .status.R,
+    .status.W {
+      background-color: var(--dark-remove-highlight-color);
+    }
+    .status.U {
+      background-color: var(--comment-background-color);
+    }
+    .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(--positive-green-text-color);
+    }
+    .removed {
+      color: var(--negative-red-text-color);
+      text-align: left;
+      min-width: 4em;
+      padding-left: var(--spacing-s);
+    }
+    .drafts {
+      color: #c62828;
+      font-weight: var(--font-weight-bold);
+    }
+    .show-hide-icon:focus {
+      outline: none;
+    }
+    .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;
+    }
+    .reviewedSwitch {
+      color: var(--link-color);
+      opacity: 0;
+      justify-content: flex-end;
+      width: 100%;
+    }
+    .reviewedSwitch:hover {
+      cursor: pointer;
+      opacity: 100;
+    }
+    .row:focus {
+      outline: none;
+    }
+    .row:hover .reviewedSwitch,
+    .row:focus-within .reviewedSwitch,
+    .row.expanded .reviewedSwitch {
+      opacity: 100;
+    }
+    .reviewedLabel {
+      color: var(--deemphasized-text-color);
+      margin-right: var(--spacing-l);
+      opacity: 0;
+    }
+    .reviewedLabel.isReviewed {
+      display: initial;
+      opacity: 100;
+    }
+    .editFileControls {
+      width: 7em;
+    }
+    .markReviewed:focus {
+      outline: none;
+    }
+    .markReviewed,
+    .pathLink {
+      display: inline-block;
+      margin: -2px 0;
+      padding: var(--spacing-s) 0;
+      text-decoration: none;
+    }
+    .pathLink:hover span.fullFileName,
+    .pathLink:hover span.truncatedFileName {
+      text-decoration: underline;
+    }
+
+    /** copy on file path **/
+    .pathLink gr-copy-clipboard,
+    .oldPath gr-copy-clipboard {
+      display: inline-block;
+      visibility: hidden;
+      vertical-align: bottom;
+      --gr-button: {
+        padding: 0px;
+      }
+    }
+    .row:focus-within gr-copy-clipboard,
+    .row: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;
+      }
+    }
+    :host(.hideComments) {
+      --gr-comment-thread-display: none;
+    }
+  </style>
+  <h3 class="assistive-tech-only">File list</h3>
+  <div
+    id="container"
+    on-click="_handleFileListClick"
+    role="grid"
+    aria-label="Files list"
+  >
+    <div class="header-row row" role="row">
+      <!-- endpoint: change-view-file-list-header-prepend -->
+      <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+        <template
+          is="dom-repeat"
+          items="[[_dynamicPrependedHeaderEndpoints]]"
+          as="headerEndpoint"
+        >
+          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
+            <gr-endpoint-param
+              name="change"
+              value="[[change]]"
+            ></gr-endpoint-param>
+            <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
+        </template>
+      </template>
+      <div class="path" role="columnheader">File</div>
+      <div class="comments" role="columnheader">Comments</div>
+      <div class="sizeBars" role="columnheader">Size</div>
+      <div class="header-stats" role="columnheader">Delta</div>
+      <!-- endpoint: change-view-file-list-header -->
+      <template is="dom-if" if="[[_showDynamicColumns]]">
+        <template
+          is="dom-repeat"
+          items="[[_dynamicHeaderEndpoints]]"
+          as="headerEndpoint"
+        >
+          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
+          </gr-endpoint-decorator>
+        </template>
+      </template>
+      <!-- Empty div here exists to keep spacing in sync with file rows. -->
+      <div
+        class="reviewed hideOnEdit"
+        hidden$="[[!_loggedIn]]"
+        aria-hidden="true"
+      ></div>
+      <div class="editFileControls showOnEdit" aria-hidden="true"></div>
+      <div class="show-hide" aria-hidden="true"></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$="[[_computePatchSetFile(file)]]"
+          tabindex="-1"
+          role="row"
+        >
+          <!-- endpoint: change-view-file-list-content-prepend -->
+          <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+            <template
+              is="dom-repeat"
+              items="[[_dynamicPrependedContentEndpoints]]"
+              as="contentEndpoint"
+            >
+              <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
+                <gr-endpoint-param name="change" value="[[change]]">
+                </gr-endpoint-param>
+                <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>
+            </template>
+          </template>
+          <!-- TODO: Remove data-url as it appears its not used -->
+          <span
+            data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
+            class="path"
+            role="gridcell"
+          >
+            <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>
+              <span
+                class$="[[_computeStatusClass(file)]]"
+                tabindex="0"
+                title$="[[_computeFileStatusLabel(file.status)]]"
+                aria-label$="[[_computeFileStatusLabel(file.status)]]"
+              >
+                [[_computeFileStatusLabel(file.status)]]
+              </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 role="gridcell">
+            <div class="comments desktop">
+              <span class="drafts"
+                ><!-- This comments ensure that span is empty when the function
+                returns empty string.
+              -->[[_computeDraftsString(changeComments, patchRange,
+                file.__path)]]<!-- This comments ensure that span is empty when
+                the function returns empty string.
+           --></span
+              >
+              <span
+                ><!--
+              -->[[_computeCommentsString(changeComments, patchRange,
+                file.__path)]]<!--
+           --></span
+              >
+              <span class="noCommentsScreenReaderText">
+                <!-- Screen readers read the following content only if 2 other
+              spans in the parent div is empty. The content is not visible on
+              the page.
+              Without this span, screen readers don't navigate correctly inside
+              table, because empty div doesn't rendered. For example, VoiceOver
+              jumps back to the whole table.
+              We can use &nbsp instead, but it sounds worse.
+              -->
+                No comments
+              </span>
+            </div>
+            <div class="comments mobile">
+              <span class="drafts"
+                ><!-- This comments ensure that span is empty when the function
+                returns empty string.
+              -->[[_computeDraftsStringMobile(changeComments, patchRange,
+                file.__path)]]<!-- This comments ensure that span is empty when
+                the function returns empty string.
+           --></span
+              >
+              <span
+                ><!--
+             -->[[_computeCommentsStringMobile(changeComments, patchRange,
+                file.__path)]]<!--
+           --></span
+              >
+              <span class="noCommentsScreenReaderText">
+                <!-- The same as for desktop comments -->
+                No comments
+              </span>
+            </div>
+          </div>
+          <div role="gridcell">
+            <!-- The content must be in a separate div. It guarantees, that
+              gridcell always visible for screen readers.
+              For example, without a nested div screen readers pronounce the
+              "Commit message" row content with incorrect column headers.
+            -->
+            <div
+              class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]"
+              aria-label="A bar that represents the addition and deletion ratio for the current file"
+            >
+              <svg width="61" height="8">
+                <rect
+                  x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
+                  y="0"
+                  height="8"
+                  fill="var(--positive-green-text-color)"
+                  width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
+                ></rect>
+                <rect
+                  x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
+                  y="0"
+                  height="8"
+                  fill="var(--negative-red-text-color)"
+                  width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
+                ></rect>
+              </svg>
+            </div>
+          </div>
+          <div class="stats" role="gridcell">
+            <!-- The content must be in a separate div. It guarantees, that
+            gridcell always visible for screen readers.
+            For example, without a nested div screen readers pronounce the
+            "Commit message" row content with incorrect column headers.
+            -->
+            <div class$="[[_computeClass('', 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>
+          </div>
+          <!-- endpoint: change-view-file-list-content -->
+          <template is="dom-if" if="[[_showDynamicColumns]]">
+            <template
+              is="dom-repeat"
+              items="[[_dynamicContentEndpoints]]"
+              as="contentEndpoint"
+            >
+              <div class$="[[_computeClass('', file.__path)]]" role="gridcell">
+                <gr-endpoint-decorator name="[[contentEndpoint]]">
+                  <gr-endpoint-param name="change" value="[[change]]">
+                  </gr-endpoint-param>
+                  <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"
+            role="gridcell"
+            hidden$="[[!_loggedIn]]"
+          >
+            <span
+              class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]"
+              aria-hidden$="[[!file.isReviewed]]"
+              >Reviewed</span
+            >
+            <!-- Do not use input type="checkbox" with hidden input and
+                  visible label here. Screen readers don't read/interract
+                  correctly with such input.
+              -->
+            <span
+              class="reviewedSwitch"
+              role="switch"
+              tabindex="0"
+              on-click="_reviewedClick"
+              on-keydown="_reviewedClick"
+              aria-label="Reviewed"
+              aria-checked$="[[_booleanToString(file.isReviewed)]]"
+            >
+              <!-- Trick with tabindex to avoid outline on mouse focus, but
+                preserve focus outline for keyboard navigation -->
+              <span
+                tabindex="-1"
+                class="markReviewed"
+                title$="[[_reviewedTitle(file.isReviewed)]]"
+                >[[_computeReviewedText(file.isReviewed)]]</span
+              >
+            </span>
+          </div>
+          <div
+            class="editFileControls showOnEdit"
+            role="gridcell"
+            aria-hidden$="[[!editMode]]"
+          >
+            <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" role="gridcell">
+            <!-- Do not use input type="checkbox" with hidden input and
+                visible label here. Screen readers don't read/interract
+                correctly with such input.
+            -->
+            <span
+              class="show-hide"
+              data-path$="[[file.__path]]"
+              data-expand="true"
+              role="switch"
+              tabindex="0"
+              aria-checked$="[[_isFileExpandedStr(file.__path, _expandedFiles.*)]]"
+              aria-label="Expand file"
+              on-click="_expandedClick"
+              on-keydown="_expandedClick"
+            >
+              <!-- Trick with tabindex to avoid outline on mouse focus, but
+              preserve focus outline for keyboard navigation -->
+              <iron-icon
+                class="show-hide-icon"
+                tabindex="-1"
+                id="icon"
+                icon="[[_computeShowHideIcon(file.__path, _expandedFiles.*)]]"
+              >
+              </iron-icon>
+            </span>
+          </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]]"
+            change="[[change]]"
+            patch-range="[[patchRange]]"
+            file="[[_computePatchSetFile(file)]]"
+            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">
+      <div>
+        <span
+          class="added"
+          tabindex="0"
+          aria-label$="Total [[_patchChange.inserted]] lines added"
+        >
+          +[[_patchChange.inserted]]
+        </span>
+        <span
+          class="removed"
+          tabindex="0"
+          aria-label$="Total [[_patchChange.deleted]] lines removed"
+        >
+          -[[_patchChange.deleted]]
+        </span>
+      </div>
+    </div>
+    <!-- endpoint: change-view-file-list-summary -->
+    <template is="dom-if" if="[[_showDynamicColumns]]">
+      <template
+        is="dom-repeat"
+        items="[[_dynamicSummaryEndpoints]]"
+        as="summaryEndpoint"
+      >
+        <gr-endpoint-decorator name="[[summaryEndpoint]]">
+          <gr-endpoint-param
+            name="change"
+            value="[[change]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+          </gr-endpoint-param>
+        </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 bytes inserted: [[_formatBytes(_patchChange.size_delta_inserted)]] "
+      >
+        [[_formatBytes(_patchChange.size_delta_inserted)]]
+        [[_formatPercentage(_patchChange.total_size,
+        _patchChange.size_delta_inserted)]]
+      </span>
+      <span
+        class="removed"
+        aria-label$="Total bytes removed: [[_formatBytes(_patchChange.size_delta_deleted)]]"
+      >
+        [[_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-mode="keep-visible"
+    focus-on-move=""
+    cursor-target-class="selected"
+  ></gr-cursor-manager>
+`;
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
deleted file mode 100644
index 32945e4..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ /dev/null
@@ -1,1942 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-file-list</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="/components/web-component-tester/data/a11ySuite.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<dom-module id="comment-api-mock">
-  <template>
-    <gr-file-list id="fileList"
-        change-comments="[[_changeComments]]"
-        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-  </template>
-  </dom-module>
-
-<test-fixture id="basic">
-  <template>
-    <comment-api-mock></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 {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';
-
-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', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getDiffPreferences() { return Promise.resolve({}); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-      });
-      stub('gr-date-formatter', {
-        _loadTimeFormat() { return Promise.resolve(''); },
-      });
-      stub('gr-diff-host', {
-        reload() { return Promise.resolve(); },
-      });
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sandbox.stub(element.changeComments, 'getPaths').returns({});
-        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
-            .returns({meta: {}, left: [], right: []});
-        done();
-      });
-      element._loading = false;
-      element.diffPrefs = {};
-      element.numFilesShown = 200;
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      saveStub = sandbox.stub(element, '_saveReviewedState',
-          () => Promise.resolve());
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('correct number of files are shown', () => {
-      element.fileListIncrement = 300;
-      element._filesByPath = _.range(500)
-          .reduce((_filesByPath, i) => {
-            _filesByPath['/file' + i] = {lines_inserted: 9};
-            return _filesByPath;
-          }, {});
-
-      flushAsynchronousOperations();
-      assert.equal(
-          dom(element.root).querySelectorAll('.file-row').length,
-          element.numFilesShown);
-      const controlRow = element.shadowRoot
-          .querySelector('.controlRow');
-      assert.isFalse(controlRow.classList.contains('invisible'));
-      assert.equal(element.$.incrementButton.textContent.trim(),
-          'Show 300 more');
-      assert.equal(element.$.showAllButton.textContent.trim(),
-          'Show all 500 files');
-
-      MockInteractions.tap(element.$.showAllButton);
-      flushAsynchronousOperations();
-
-      assert.equal(element.numFilesShown, 500);
-      assert.equal(element._shownFiles.length, 500);
-      assert.isTrue(controlRow.classList.contains('invisible'));
-    });
-
-    test('rendering each row calls the _reportRenderedRow method', () => {
-      const renderedStub = sandbox.stub(element, '_reportRenderedRow');
-      element._filesByPath = _.range(10)
-          .reduce((_filesByPath, i) => {
-            _filesByPath['/file' + i] = {lines_inserted: 9};
-            return _filesByPath;
-          }, {});
-      flushAsynchronousOperations();
-      assert.equal(
-          dom(element.root).querySelectorAll('.file-row').length, 10);
-      assert.equal(renderedStub.callCount, 10);
-    });
-
-    test('calculate totals for patch number', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {
-          lines_inserted: 9,
-        },
-        '/MERGE_LIST': {
-          lines_inserted: 9,
-        },
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with a commit message that isn't the first file.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-        '/COMMIT_MSG': {
-          lines_inserted: 9,
-        },
-        '/MERGE_LIST': {
-          lines_inserted: 9,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with no commit message.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with files missing either lines_inserted or lines_deleted.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {lines_inserted: 1},
-        'myfile.txt': {lines_deleted: 1},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 1,
-        deleted: 1,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-    });
-
-    test('binary only files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_binary_1': {binary: true, size_delta: 10, size: 100},
-        'file_binary_2': {binary: true, size_delta: -5, size: 120},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 0,
-        deleted: 0,
-        size_delta_inserted: 10,
-        size_delta_deleted: -5,
-        total_size: 220,
-      });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isTrue(element._hideChangeTotals);
-    });
-
-    test('binary and regular files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_binary_1': {binary: true, size_delta: 10, size: 100},
-        'file_binary_2': {binary: true, size_delta: -5, size: 120},
-        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
-        'myfile2.txt': {lines_inserted: 10},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 10,
-        deleted: 5,
-        size_delta_inserted: 10,
-        size_delta_deleted: -5,
-        total_size: 220,
-      });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-    });
-
-    test('_formatBytes function', () => {
-      const table = {
-        '64': '+64 B',
-        '1023': '+1023 B',
-        '1024': '+1 KiB',
-        '4096': '+4 KiB',
-        '1073741824': '+1 GiB',
-        '-64': '-64 B',
-        '-1023': '-1023 B',
-        '-1024': '-1 KiB',
-        '-4096': '-4 KiB',
-        '-1073741824': '-1 GiB',
-        '0': '+/-0 B',
-      };
-
-      for (const bytes in table) {
-        if (table.hasOwnProperty(bytes)) {
-          assert.equal(element._formatBytes(bytes), table[bytes]);
-        }
-      }
-    });
-
-    test('_formatPercentage function', () => {
-      const table = [
-        {size: 100,
-          delta: 100,
-          display: '',
-        },
-        {size: 195060,
-          delta: 64,
-          display: '(+0%)',
-        },
-        {size: 195060,
-          delta: -64,
-          display: '(-0%)',
-        },
-        {size: 394892,
-          delta: -7128,
-          display: '(-2%)',
-        },
-        {size: 90,
-          delta: -10,
-          display: '(-10%)',
-        },
-        {size: 110,
-          delta: 10,
-          display: '(+10%)',
-        },
-      ];
-
-      for (const item of table) {
-        assert.equal(element._formatPercentage(
-            item.size, item.delta), item.display);
-      }
-    });
-
-    test('comment filtering', () => {
-      element.changeComments._comments = {
-        '/COMMIT_MSG': [
-          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
-          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
-          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
-        ],
-        'myfile.txt': [
-          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
-          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
-          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
-        ],
-        'unresolved.file': [
-          {
-            patch_set: 2,
-            message: 'wat!?',
-            updated: '2017-02-09 16:40:49',
-            id: '1',
-            unresolved: true,
-          },
-          {
-            patch_set: 2,
-            message: 'hi',
-            updated: '2017-02-10 16:40:49',
-            id: '2',
-            in_reply_to: '1',
-            unresolved: false,
-          },
-          {
-            patch_set: 2,
-            message: 'good news!',
-            updated: '2017-02-08 16:40:49',
-            id: '3',
-            unresolved: true,
-          },
-        ],
-      };
-      element.changeComments._drafts = {
-        '/COMMIT_MSG': [
-          {
-            patch_set: 1,
-            message: 'hi',
-            updated: '2017-02-15 16:40:49',
-            id: '5',
-            unresolved: true,
-          },
-          {
-            patch_set: 1,
-            message: 'fyi',
-            updated: '2017-02-15 16:40:49',
-            id: '6',
-            unresolved: false,
-          },
-        ],
-        'unresolved.file': [
-          {
-            patch_set: 1,
-            message: 'hi',
-            updated: '2017-02-11 16:40:49',
-            id: '4',
-            unresolved: false,
-          },
-        ],
-      };
-
-      const parentTo1 = {
-        basePatchNum: 'PARENT',
-        patchNum: '1',
-      };
-
-      const parentTo2 = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-
-      const _1To2 = {
-        basePatchNum: '1',
-        patchNum: '2',
-      };
-
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, parentTo1
-              , '/COMMIT_MSG'), '2c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2
-              , '/COMMIT_MSG'), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              'unresolved.file'), '1 draft');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              'unresolved.file'), '1 draft');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'unresolved.file'), '1d');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'unresolved.file'), '1d');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              'myfile.txt', 'comment'), '1 comment');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'myfile.txt', 'comment'), '3 comments');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo1,
-              'myfile.txt'
-          ), '1c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo1,
-              'file_added_in_rev2.txt'
-          ), '');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              '/COMMIT_MSG', 'comment'), '1 comment');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo2,
-              '/COMMIT_MSG'
-          ), '1c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              '/COMMIT_MSG'), '2 drafts');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '2 drafts');
-      assert.equal(
-          element._computeDraftsStringMobile(
-              element.changeComments,
-              parentTo1,
-              '/COMMIT_MSG'
-          ), '2d');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '2d');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'myfile.txt', 'comment'), '2 comments');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'myfile.txt', 'comment'), '3 comments');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo2,
-              'myfile.txt'
-          ), '2c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '3c');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo2,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
-    });
-
-    test('_reviewedTitle', () => {
-      assert.equal(
-          element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');
-
-      assert.equal(
-          element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
-    });
-
-    suite('keyboard shortcuts', () => {
-      setup(() => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {},
-          'file_added_in_rev2.txt': {},
-          'myfile.txt': {},
-        };
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
-        element.change = {_number: 42};
-        element.$.fileCursor.setCursorAtIndex(0);
-      });
-
-      test('toggle left diff via shortcut', () => {
-        const toggleLeftDiffStub = sandbox.stub();
-        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
-        // https://github.com/sinonjs/sinon/issues/781
-        const diffsStub = sinon.stub(element, 'diffs', {
-          get() {
-            return [{toggleLeftDiff: toggleLeftDiffStub}];
-          },
-        });
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-        assert.isTrue(toggleLeftDiffStub.calledOnce);
-        diffsStub.restore();
-      });
-
-      test('keyboard shortcuts', () => {
-        flushAsynchronousOperations();
-
-        const items = dom(element.root).querySelectorAll('.file-row');
-        element.$.fileCursor.stops = items;
-        element.$.fileCursor.setCursorAtIndex(0);
-        assert.equal(items.length, 3);
-        assert.isTrue(items[0].classList.contains('selected'));
-        assert.isFalse(items[1].classList.contains('selected'));
-        assert.isFalse(items[2].classList.contains('selected'));
-        // j with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
-        assert.equal(element.$.fileCursor.index, 0);
-        // down should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
-        assert.equal(element.$.fileCursor.index, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        assert.equal(element.$.fileCursor.index, 1);
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-
-        const navStub = sandbox.stub(GerritNav, 'navigateToDiff');
-        assert.equal(element.$.fileCursor.index, 2);
-        assert.equal(element.selectedIndex, 2);
-
-        // k with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
-        assert.equal(element.$.fileCursor.index, 2);
-
-        // up should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
-        assert.equal(element.$.fileCursor.index, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.$.fileCursor.index, 1);
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
-
-        assert(navStub.lastCall.calledWith(element.change,
-            'file_added_in_rev2.txt', '2'),
-        'Should navigate to /c/42/2/file_added_in_rev2.txt');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.$.fileCursor.index, 0);
-        assert.equal(element.selectedIndex, 0);
-
-        const createCommentInPlaceStub = sandbox.stub(element.$.diffCursor,
-            'createCommentInPlace');
-        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
-        assert.isTrue(createCommentInPlaceStub.called);
-      });
-
-      test('i key shows/hides selected inline diff', () => {
-        const paths = Object.keys(element._filesByPath);
-        sandbox.stub(element, '_expandedFilesChanged');
-        flushAsynchronousOperations();
-        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.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.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-
-        element.$.fileCursor.setCursorAtIndex(1);
-        MockInteractions.keyUpOn(element, 73, null, 'i');
-        flushAsynchronousOperations();
-        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.isTrue(
-              element._expandedFiles
-                  .some(f => f.path === element.diffs[index].path)
-          );
-        }
-
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-        flushAsynchronousOperations();
-        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 getNumReviewed = () => element._files.reduce(reducer, 0);
-        flushAsynchronousOperations();
-
-        // Default state should be unreviewed.
-        assert.equal(getNumReviewed(), 0);
-
-        // Press the review key to toggle it (set the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        flushAsynchronousOperations();
-        assert.equal(getNumReviewed(), 1);
-
-        // Press the review key to toggle it (clear the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.equal(getNumReviewed(), 0);
-      });
-
-      suite('_handleOpenFile', () => {
-        let interact;
-
-        setup(() => {
-          sandbox.stub(element, 'shouldSuppressKeyboardShortcut')
-              .returns(false);
-          sandbox.stub(element, 'modifierPressed').returns(false);
-          const openCursorStub = sandbox.stub(element, '_openCursorFile');
-          const openSelectedStub = sandbox.stub(element, '_openSelectedFile');
-          const expandStub = sandbox.stub(element, '_toggleFileExpanded');
-
-          interact = function(opt_payload) {
-            openCursorStub.reset();
-            openSelectedStub.reset();
-            expandStub.reset();
-
-            const e = new CustomEvent('fake-keyboard-event', opt_payload);
-            sinon.stub(e, 'preventDefault');
-            element._handleOpenFile(e);
-            assert.isTrue(e.preventDefault.called);
-            const result = {};
-            if (openCursorStub.called) {
-              result.opened_cursor = true;
-            }
-            if (openSelectedStub.called) {
-              result.opened_selected = true;
-            }
-            if (expandStub.called) {
-              result.expanded = true;
-            }
-            return result;
-          };
-        });
-
-        test('open from selected file', () => {
-          element._showInlineDiffs = false;
-          assert.deepEqual(interact(), {opened_selected: true});
-        });
-
-        test('open from diff cursor', () => {
-          element._showInlineDiffs = true;
-          assert.deepEqual(interact(), {opened_cursor: true});
-        });
-
-        test('expand when user prefers', () => {
-          element._showInlineDiffs = false;
-          assert.deepEqual(interact(), {opened_selected: true});
-          element._userPrefs = {};
-          assert.deepEqual(interact(), {opened_selected: true});
-        });
-      });
-
-      test('shift+left/shift+right', () => {
-        const moveLeftStub = sandbox.stub(element.$.diffCursor, 'moveLeft');
-        const moveRightStub = sandbox.stub(element.$.diffCursor, 'moveRight');
-
-        let noDiffsExpanded = true;
-        sandbox.stub(element, '_noDiffsExpanded', () => noDiffsExpanded);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
-        assert.isFalse(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
-        assert.isFalse(moveRightStub.called);
-
-        noDiffsExpanded = false;
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
-        assert.isTrue(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
-        assert.isTrue(moveRightStub.called);
-      });
-    });
-
-    test('computed properties', () => {
-      assert.equal(element._computeFileStatus('A'), 'A');
-      assert.equal(element._computeFileStatus(undefined), 'M');
-      assert.equal(element._computeFileStatus(null), 'M');
-
-      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
-      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
-          'clazz invisible');
-    });
-
-    test('file review status', () => {
-      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'file_added_in_rev2.txt': {},
-        'myfile.txt': {},
-      };
-      element._loggedIn = true;
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element.$.fileCursor.setCursorAtIndex(0);
-
-      flushAsynchronousOperations();
-      const fileRows =
-          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);
-      const myFile = fileRows[2].querySelector(checkSelector);
-
-      assert.isTrue(commitMsg.checked);
-      assert.isFalse(fileAdded.checked);
-      assert.isTrue(myFile.checked);
-
-      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
-      const markReviewLabel = commitMsg.nextElementSibling;
-      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-
-      const clickSpy = sandbox.spy(element, '_handleFileListClick');
-      MockInteractions.tap(markReviewLabel);
-      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
-      assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
-      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
-
-      MockInteractions.tap(markReviewLabel);
-      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
-      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
-    });
-
-    test('_computeFileStatusLabel', () => {
-      assert.equal(element._computeFileStatusLabel('A'), 'Added');
-      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
-    });
-
-    test('_handleFileListClick', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'f1.txt': {},
-        'f2.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-
-      const clickSpy = sandbox.spy(element, '_handleFileListClick');
-      const reviewStub = sandbox.stub(element, '_reviewFile');
-      const toggleExpandSpy = sandbox.spy(element, '_toggleFileExpanded');
-
-      const row = dom(element.root)
-          .querySelector(`.row[data-file='{"path":"f1.txt"}']`);
-
-      // 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);
-      assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isFalse(reviewStub.called);
-
-      // Click inside the diff. This should result in no additional calls to
-      // _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 _toggleFileExpanded.
-      row.querySelector('.markReviewed').click();
-      assert.isTrue(clickSpy.calledThrice);
-      assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isTrue(reviewStub.calledOnce);
-    });
-
-    test('_handleFileListClick editMode', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'f1.txt': {},
-        'f2.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element.editMode = true;
-      flushAsynchronousOperations();
-      const clickSpy = sandbox.spy(element, '_handleFileListClick');
-      const toggleExpandSpy = sandbox.spy(element, '_toggleFileExpanded');
-
-      // Tap the edit controls. Should be ignored by _handleFileListClick.
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.editFileControls'));
-      assert.isTrue(clickSpy.calledOnce);
-      assert.isFalse(toggleExpandSpy.called);
-    });
-
-    test('patch set from revisions', () => {
-      const expected = [
-        {num: 4, desc: 'test', sha: 'rev4'},
-        {num: 3, desc: 'test', sha: 'rev3'},
-        {num: 2, desc: 'test', sha: 'rev2'},
-        {num: 1, desc: 'test', sha: 'rev1'},
-      ];
-      const patchNums = element.computeAllPatchSets({
-        revisions: {
-          rev3: {_number: 3, description: 'test', date: 3},
-          rev1: {_number: 1, description: 'test', date: 1},
-          rev4: {_number: 4, description: 'test', date: 4},
-          rev2: {_number: 2, description: 'test', date: 2},
-        },
-      });
-      assert.equal(patchNums.length, expected.length);
-      for (let i = 0; i < expected.length; i++) {
-        assert.deepEqual(patchNums[i], expected[i]);
-      }
-    });
-
-    test('checkbox shows/hides diff inline', () => {
-      element._filesByPath = {
-        'myfile.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element.$.fileCursor.setCursorAtIndex(0);
-      sandbox.stub(element, '_expandedFilesChanged');
-      flushAsynchronousOperations();
-      const fileRows =
-          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');
-      const showHideCheck = fileRows[0].querySelector(
-          'input.show-hide[type="checkbox"]');
-      assert.isNotOk(showHideCheck.checked);
-      MockInteractions.tap(showHideLabel);
-      assert.isOk(showHideCheck.checked);
-      assert.notEqual(
-          element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
-          -1);
-    });
-
-    test('diff mode correctly toggles the diffs', () => {
-      element._filesByPath = {
-        'myfile.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      sandbox.spy(element, '_updateDiffPreferences');
-      element.$.fileCursor.setCursorAtIndex(0);
-      flushAsynchronousOperations();
-
-      // Tap on a file to generate the diff.
-      const row = dom(element.root)
-          .querySelectorAll('.row:not(.header-row) label.show-hide')[0];
-
-      MockInteractions.tap(row);
-      flushAsynchronousOperations();
-      const diffDisplay = element.diffs[0];
-      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
-      element.set('diffViewMode', 'UNIFIED_DIFF');
-      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
-      assert.isTrue(element._updateDiffPreferences.called);
-    });
-
-    test('expanded attribute not set on path when not expanded', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-      };
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.expanded'));
-    });
-
-    test('tapping row ignores links', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      sandbox.stub(element, '_expandedFilesChanged');
-      flushAsynchronousOperations();
-      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, '_toggleFileExpanded');
-
-      MockInteractions.tap(commitMsgFile);
-      flushAsynchronousOperations();
-      assert(togglePathSpy.notCalled, 'file is opened as diff view');
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.expanded'));
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.show-hide')).display,
-      'none');
-    });
-
-    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.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.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-less');
-
-      assert.equal(renderSpy.callCount, 1);
-      assert.isTrue(element._expandedFiles.some(f => f.path === path));
-      element._toggleFileExpanded({path});
-      flushAsynchronousOperations();
-
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
-      assert.equal(renderSpy.callCount, 1);
-      assert.isFalse(element._expandedFiles.some(f => f.path === path));
-      assert.equal(collapseStub.lastCall.args[0].length, 1);
-    });
-
-    test('expandAllDiffs and collapseAllDiffs', () => {
-      const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
-      const cursorUpdateStub = sandbox.stub(element.$.diffCursor,
-          'handleDiffUpdate');
-
-      const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {}};
-      element.expandAllDiffs();
-      flushAsynchronousOperations();
-      assert.isTrue(element._showInlineDiffs);
-      assert.isTrue(cursorUpdateStub.calledOnce);
-      assert.equal(collapseStub.lastCall.args[0].length, 0);
-
-      element.collapseAllDiffs();
-      flushAsynchronousOperations();
-      assert.equal(element._expandedFiles.length, 0);
-      assert.isFalse(element._showInlineDiffs);
-      assert.isTrue(cursorUpdateStub.calledTwice);
-      assert.equal(collapseStub.lastCall.args[0].length, 1);
-    });
-
-    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) {
-          if (['render-start', 'render-content', 'scroll']
-              .indexOf(eventName) >= 0) {
-            callback(new Event(eventName));
-          }
-        },
-      }];
-      sinon.stub(element, 'diffs', {
-        get() { return diffs; },
-      });
-      element.push('_expandedFiles', {path});
-    });
-
-    test('_clearCollapsedDiffs', () => {
-      const diff = {
-        cancel: sinon.stub(),
-        clearDiffContent: sinon.stub(),
-      };
-      element._clearCollapsedDiffs([diff]);
-      assert.isTrue(diff.cancel.calledOnce);
-      assert.isTrue(diff.clearDiffContent.calledOnce);
-    });
-
-    test('filesExpanded value updates to correct enum', () => {
-      element._filesByPath = {
-        'foo.bar': {},
-        'baz.bar': {},
-      };
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.NONE);
-      element.push('_expandedFiles', {path: 'baz.bar'});
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.SOME);
-      element.push('_expandedFiles', {path: 'foo.bar'});
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.ALL);
-      element.collapseAllDiffs();
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.NONE);
-      element.expandAllDiffs();
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.ALL);
-    });
-
-    test('_renderInOrder', done => {
-      const reviewStub = sandbox.stub(element, '_reviewFile');
-      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([
-        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.isFalse(reviewStub.called);
-            assert.isTrue(loadCommentSpy.called);
-            done();
-          });
-    });
-
-    test('_renderInOrder logged in', done => {
-      element._loggedIn = true;
-      const reviewStub = sandbox.stub(element, '_reviewFile');
-      let callCount = 0;
-      const diffs = [{
-        path: 'p0',
-        style: {},
-        reload() {
-          assert.equal(reviewStub.callCount, 2);
-          assert.equal(callCount++, 2);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p1',
-        style: {},
-        reload() {
-          assert.equal(reviewStub.callCount, 1);
-          assert.equal(callCount++, 1);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p2',
-        style: {},
-        reload() {
-          assert.equal(reviewStub.callCount, 0);
-          assert.equal(callCount++, 0);
-          return Promise.resolve();
-        },
-      }];
-      element._renderInOrder([
-        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.equal(reviewStub.callCount, 3);
-            done();
-          });
-    });
-
-    test('_renderInOrder respects diffPrefs.manual_review', () => {
-      element._loggedIn = true;
-      element.diffPrefs = {manual_review: true};
-      const reviewStub = sandbox.stub(element, '_reviewFile');
-      const diffs = [{
-        path: 'p',
-        style: {},
-        reload() { return Promise.resolve(); },
-      }];
-
-      return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-        assert.isFalse(reviewStub.called);
-        delete element.diffPrefs.manual_review;
-        return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-          assert.isTrue(reviewStub.called);
-          assert.isTrue(reviewStub.calledWithExactly('p', true));
-        });
-      });
-    });
-
-    test('_loadingChanged fired from reload in debouncer', done => {
-      sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
-      element.changeNum = 123;
-      element.patchRange = {patchNum: 12};
-      element._filesByPath = {'foo.bar': {}};
-
-      element.reload().then(() => {
-        assert.isFalse(element._loading);
-        element.flushDebouncer('loading-change');
-        assert.isFalse(element.classList.contains('loading'));
-        done();
-      });
-      assert.isTrue(element._loading);
-      assert.isFalse(element.classList.contains('loading'));
-      element.flushDebouncer('loading-change');
-      assert.isTrue(element.classList.contains('loading'));
-    });
-
-    test('_loadingChanged does not set class when there are no files', () => {
-      sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
-      element.changeNum = 123;
-      element.patchRange = {patchNum: 12};
-      element.reload();
-      assert.isTrue(element._loading);
-      element.flushDebouncer('loading-change');
-      assert.isFalse(element.classList.contains('loading'));
-    });
-  });
-
-  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();
-    });
-
-    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,
-      });
-
-      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('_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,
-      };
-
-      // Uses half the space when file is half the largest addition and there
-      // are no deletions.
-      assert.equal(element._computeBarAdditionWidth(file, stats), 30);
-
-      // If there are no insetions, there is no width.
-      stats.maxInserted = 0;
-      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);
-
-      // If the file is a commit message, returns zero.
-      file.lines_inserted = 5;
-      file.__path = '/COMMIT_MSG';
-      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);
-    });
-
-    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);
-    });
-
-    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,
-      };
-
-      // 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 there are no deletions, there is no width.
-      stats.maxDeleted = 0;
-      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);
-
-      // If the file is a commit message, returns zero.
-      file.lines_deleted = 5;
-      file.__path = '/COMMIT_MSG';
-      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);
-    });
-
-    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 ');
-    });
-  });
-
-  suite('gr-file-list inline diff tests', () => {
-    let element;
-    let sandbox;
-
-    const commitMsgComments = [
-      {
-        patch_set: 2,
-        id: 'ecf0b9fa_fe1a5f62',
-        line: 20,
-        updated: '2018-02-08 18:49:18.000000000',
-        message: 'another comment',
-        unresolved: true,
-      },
-      {
-        patch_set: 2,
-        id: '503008e2_0ab203ee',
-        line: 10,
-        updated: '2018-02-14 22:07:43.000000000',
-        message: 'a comment',
-        unresolved: true,
-      },
-      {
-        patch_set: 2,
-        id: 'cc788d2c_cb1d728c',
-        line: 20,
-        in_reply_to: 'ecf0b9fa_fe1a5f62',
-        updated: '2018-02-13 22:07:43.000000000',
-        message: 'response',
-        unresolved: true,
-      },
-    ];
-
-    const setupDiff = function(diff) {
-      diff.comments = {
-        left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
-        right: [],
-        meta: {
-          changeNum: 1,
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 2,
-          },
-        },
-      };
-      diff.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',
-      };
-      diff.diff = getMockDiffResponse();
-      diff.$.diff.flushDebouncer('renderDiffTable');
-    };
-
-    const renderAndGetNewDiffs = function(index) {
-      const diffs =
-          dom(element.root).querySelectorAll('gr-diff-host');
-
-      for (let i = index; i < diffs.length; i++) {
-        setupDiff(diffs[i]);
-      }
-
-      element._updateDiffCursor();
-      element.$.diffCursor.handleDiffUpdate();
-      return diffs;
-    };
-
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
-      stub('gr-date-formatter', {
-        _loadTimeFormat() { return Promise.resolve(''); },
-      });
-      stub('gr-diff-host', {
-        reload() { return Promise.resolve(); },
-      });
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
-      element.diffPrefs = {};
-      sandbox.stub(element, '_reviewFile');
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sandbox.stub(element.changeComments, 'getPaths').returns({});
-        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
-            .returns({meta: {}, left: [], right: []});
-        done();
-      });
-      element._loading = false;
-      element.numFilesShown = 75;
-      element.selectedIndex = 0;
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-      };
-      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._loggedIn = true;
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      sandbox.stub(window, 'fetch', () => Promise.resolve());
-      flushAsynchronousOperations();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('cursor with individually opened files', () => {
-      MockInteractions.keyUpOn(element, 73, null, 'i');
-      flushAsynchronousOperations();
-      let diffs = renderAndGetNewDiffs(0);
-      const diffStops = diffs[0].getCursorStops();
-
-      // 1 diff should be rendered.
-      assert.equal(diffs.length, 1);
-
-      // No line number is selected.
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-      // Tapping content on a line selects the line number.
-      MockInteractions.tap(dom(
-          diffStops[10]).querySelectorAll('.contentText')[0]);
-      flushAsynchronousOperations();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-      // Keyboard shortcuts are still moving the file cursor, not the diff
-      // cursor.
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      flushAsynchronousOperations();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-      assert.isFalse(diffStops[11].classList.contains('target-row'));
-
-      // The file cusor is now at 1.
-      assert.equal(element.$.fileCursor.index, 1);
-      MockInteractions.keyUpOn(element, 73, null, 'i');
-      flushAsynchronousOperations();
-
-      diffs = renderAndGetNewDiffs(1);
-      // Two diffs should be rendered.
-      assert.equal(diffs.length, 2);
-      const diffStopsFirst = diffs[0].getCursorStops();
-      const diffStopsSecond = diffs[1].getCursorStops();
-
-      // The line on the first diff is stil selected
-      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
-      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
-    });
-
-    test('cursor with toggle all files', () => {
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      flushAsynchronousOperations();
-
-      const diffs = renderAndGetNewDiffs(0);
-      const diffStops = diffs[0].getCursorStops();
-
-      // 1 diff should be rendered.
-      assert.equal(diffs.length, 3);
-
-      // No line number is selected.
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-      // Tapping content on a line selects the line number.
-      MockInteractions.tap(dom(
-          diffStops[10]).querySelectorAll('.contentText')[0]);
-      flushAsynchronousOperations();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-      // Keyboard shortcuts are still moving the file cursor, not the diff
-      // cursor.
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      flushAsynchronousOperations();
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-      assert.isTrue(diffStops[11].classList.contains('target-row'));
-
-      // The file cusor is still at 0.
-      assert.equal(element.$.fileCursor.index, 0);
-    });
-
-    suite('n key presses', () => {
-      let nKeySpy;
-      let nextCommentStub;
-      let nextChunkStub;
-      let fileRows;
-
-      setup(() => {
-        sandbox.stub(element, '_renderInOrder').returns(Promise.resolve());
-        nKeySpy = sandbox.spy(element, '_handleNextChunk');
-        nextCommentStub = sandbox.stub(element.$.diffCursor,
-            'moveToNextCommentThread');
-        nextChunkStub = sandbox.stub(element.$.diffCursor,
-            'moveToNextChunk');
-        fileRows =
-            dom(element.root).querySelectorAll('.row:not(.header-row)');
-      });
-
-      test('n key with some files expanded and no shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
-        flushAsynchronousOperations();
-        assert.equal(nextChunkStub.callCount, 1);
-
-        // Handle N key should return before calling diff cursor functions.
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isFalse(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 2);
-        assert.equal(element.filesExpanded, 'some');
-      });
-
-      test('n key with some files expanded and shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
-        flushAsynchronousOperations();
-        assert.equal(nextChunkStub.callCount, 1);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isTrue(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
-        assert.equal(element.filesExpanded, 'some');
-      });
-
-      test('n key without all files expanded and shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
-        flushAsynchronousOperations();
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isFalse(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 2);
-        assert.isTrue(element._showInlineDiffs);
-      });
-
-      test('n key without all files expanded and no shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
-        flushAsynchronousOperations();
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isTrue(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
-        assert.isTrue(element._showInlineDiffs);
-      });
-    });
-
-    test('_openSelectedFile behavior', () => {
-      const _filesByPath = element._filesByPath;
-      element.set('_filesByPath', {});
-      const navStub = sandbox.stub(GerritNav, 'navigateToDiff');
-      // Noop when there are no files.
-      element._openSelectedFile();
-      assert.isFalse(navStub.called);
-
-      element.set('_filesByPath', _filesByPath);
-      flushAsynchronousOperations();
-      // Navigates when a file is selected.
-      element._openSelectedFile();
-      assert.isTrue(navStub.called);
-    });
-
-    test('_displayLine', () => {
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut', () => false);
-      sandbox.stub(element, 'modifierPressed', () => false);
-      element._showInlineDiffs = true;
-      const mockEvent = {preventDefault() {}};
-
-      element._displayLine = false;
-      element._handleCursorNext(mockEvent);
-      assert.isTrue(element._displayLine);
-
-      element._displayLine = false;
-      element._handleCursorPrev(mockEvent);
-      assert.isTrue(element._displayLine);
-
-      element._displayLine = true;
-      element._handleEscKey(mockEvent);
-      assert.isFalse(element._displayLine);
-    });
-
-    suite('editMode behavior', () => {
-      test('reviewed checkbox', () => {
-        element._reviewFile.restore();
-        const saveReviewStub = sandbox.stub(element, '_saveReviewedState');
-
-        element.editMode = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.isTrue(saveReviewStub.calledOnce);
-
-        element.editMode = true;
-        flushAsynchronousOperations();
-
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.isTrue(saveReviewStub.calledOnce);
-      });
-
-      test('_getReviewedFiles does not call API', () => {
-        const apiSpy = sandbox.spy(element.$.restAPI, 'getReviewedFiles');
-        element.editMode = true;
-        return element._getReviewedFiles().then(files => {
-          assert.equal(files.length, 0);
-          assert.isFalse(apiSpy.called);
-        });
-      });
-    });
-
-    test('editing actions', () => {
-      // Edit controls are guarded behind a dom-if initially and not rendered.
-      assert.isNotOk(dom(element.root)
-          .querySelector('gr-edit-file-controls'));
-
-      element.editMode = true;
-      flushAsynchronousOperations();
-
-      // Commit message should not have edit controls.
-      const editControls =
-          Array.from(
-              dom(element.root)
-                  .querySelectorAll('.row:not(.header-row)'))
-              .map(row => row.querySelector('gr-edit-file-controls'));
-      assert.isTrue(editControls[0].classList.contains('invisible'));
-    });
-
-    test('reloadCommentsForThreadWithRootId', () => {
-      // Expand the commit message diff
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      const diffs = renderAndGetNewDiffs(0);
-      flushAsynchronousOperations();
-
-      // Two comment threads should be generated by renderAndGetNewDiffs
-      const threadEls = diffs[0].getThreadEls();
-      assert.equal(threadEls.length, 2);
-      const threadElsByRootId = new Map(
-          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
-
-      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
-      assert.equal(thread1.comments.length, 1);
-      assert.equal(thread1.comments[0].message, 'a comment');
-      assert.equal(thread1.comments[0].line, 10);
-
-      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
-      assert.equal(thread2.comments.length, 2);
-      assert.isTrue(thread2.comments[0].unresolved);
-      assert.equal(thread2.comments[0].message, 'another comment');
-      assert.equal(thread2.comments[0].line, 20);
-
-      const commentStub =
-          sandbox.stub(element.changeComments, 'getCommentsForThread');
-      const commentStubRes1 = [
-        {
-          patch_set: 2,
-          id: '503008e2_0ab203ee',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'edited text',
-          unresolved: false,
-        },
-      ];
-      const commentStubRes2 = [
-        {
-          patch_set: 2,
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'another comment',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          id: '503008e2_0ab203ee',
-          line: 10,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
-          updated: '2018-02-14 22:07:43.000000000',
-          message: 'response',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          id: '503008e2_0ab203ef',
-          line: 20,
-          in_reply_to: '503008e2_0ab203ee',
-          updated: '2018-02-15 22:07:43.000000000',
-          message: 'a third comment in the thread',
-          unresolved: true,
-        },
-      ];
-      commentStub.withArgs('503008e2_0ab203ee').returns(
-          commentStubRes1);
-      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
-          commentStubRes2);
-
-      // Reload comments from the first comment thread, which should have a
-      // an updated message and a toggled resolve state.
-      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
-          '/COMMIT_MSG');
-      assert.equal(thread1.comments.length, 1);
-      assert.isFalse(thread1.comments[0].unresolved);
-      assert.equal(thread1.comments[0].message, 'edited text');
-
-      // Reload comments from the second comment thread, which should have a new
-      // reply.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.equal(thread2.comments.length, 3);
-
-      const commentStubCount = commentStub.callCount;
-      const getThreadsSpy = sandbox.spy(diffs[0], 'getThreadEls');
-
-      // Should not be getting threads when the file is not expanded.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          'other/file');
-      assert.isFalse(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-
-      // Should be query selecting diffs when the file is expanded.
-      // Should not be fetching change comments when the rootId is not found
-      // to match.
-      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.isTrue(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-    });
-  });
-  a11ySuite('basic');
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
new file mode 100644
index 0000000..d85ae4d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -0,0 +1,1964 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {listenOnce} from '../../../test/test-utils.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import './gr-file-list.js';
+import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {FilesExpandedState} from '../gr-file-list-constants.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {runA11yAudit} from '../../../test/a11y-test-utils.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
+
+const commentApiMock = createCommentApiMockWithTemplateElement(
+    'gr-file-list-comment-api-mock', html`
+    <gr-file-list id="fileList"
+        change-comments="[[_changeComments]]"
+        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
+`);
+
+const basicFixture = fixtureFromElement(commentApiMock.is);
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    await runA11yAudit(basicFixture);
+  });
+});
+
+suite('gr-file-list tests', () => {
+  let element;
+  let commentApiWrapper;
+
+  let saveStub;
+  let loadCommentSpy;
+
+  suiteSetup(() => {
+    const kb = TestKeyboardShortcutBinder.push();
+    kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
+    kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
+    kb.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+    kb.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+    kb.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+    kb.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+    kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
+    kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
+    kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
+    kb.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
+    kb.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
+    kb.bindShortcut(Shortcut.OPEN_FILE, 'o');
+    kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
+    kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
+    kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+    kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+  });
+
+  suiteTeardown(() => {
+    TestKeyboardShortcutBinder.pop();
+  });
+
+  suite('basic tests', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        getDiffPreferences() { return Promise.resolve({}); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+        getAccountCapabilities() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat() { return Promise.resolve(''); },
+      });
+      stub('gr-diff-host', {
+        reload() { return Promise.resolve(); },
+        prefetchDiff() {},
+      });
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.fileList;
+      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      commentApiWrapper.loadComments().then(() => {
+        sinon.stub(element.changeComments, 'getPaths').returns({});
+        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
+            .returns({meta: {}, left: [], right: []});
+        done();
+      });
+      element._loading = false;
+      element.diffPrefs = {};
+      element.numFilesShown = 200;
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      saveStub = sinon.stub(element, '_saveReviewedState').callsFake(
+          () => Promise.resolve());
+    });
+
+    test('correct number of files are shown', () => {
+      element.fileListIncrement = 300;
+      element._filesByPath = Array(500).fill(0)
+          .reduce((_filesByPath, _, idx) => {
+            _filesByPath['/file' + idx] = {lines_inserted: 9};
+            return _filesByPath;
+          }, {});
+
+      flush();
+      assert.equal(
+          element.root.querySelectorAll('.file-row').length,
+          element.numFilesShown);
+      const controlRow = element.shadowRoot
+          .querySelector('.controlRow');
+      assert.isFalse(controlRow.classList.contains('invisible'));
+      assert.equal(element.$.incrementButton.textContent.trim(),
+          'Show 300 more');
+      assert.equal(element.$.showAllButton.textContent.trim(),
+          'Show all 500 files');
+
+      MockInteractions.tap(element.$.showAllButton);
+      flush();
+
+      assert.equal(element.numFilesShown, 500);
+      assert.equal(element._shownFiles.length, 500);
+      assert.isTrue(controlRow.classList.contains('invisible'));
+    });
+
+    test('rendering each row calls the _reportRenderedRow method', () => {
+      const renderedStub = sinon.stub(element, '_reportRenderedRow');
+      element._filesByPath = Array(10).fill(0)
+          .reduce((_filesByPath, _, idx) => {
+            _filesByPath['/file' + idx] = {lines_inserted: 9};
+            return _filesByPath;
+          }, {});
+      flush();
+      assert.equal(
+          element.root.querySelectorAll('.file-row').length, 10);
+      assert.equal(renderedStub.callCount, 10);
+    });
+
+    test('calculate totals for patch number', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+        },
+        '/MERGE_LIST': {
+          lines_inserted: 9,
+        },
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with a commit message that isn't the first file.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+        },
+        '/MERGE_LIST': {
+          lines_inserted: 9,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with no commit message.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with files missing either lines_inserted or lines_deleted.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {lines_inserted: 1},
+        'myfile.txt': {lines_deleted: 1},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 1,
+        deleted: 1,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('binary only files', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_binary_1': {binary: true, size_delta: 10, size: 100},
+        'file_binary_2': {binary: true, size_delta: -5, size: 120},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 0,
+        deleted: 0,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isTrue(element._hideChangeTotals);
+    });
+
+    test('binary and regular files', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_binary_1': {binary: true, size_delta: 10, size: 100},
+        'file_binary_2': {binary: true, size_delta: -5, size: 120},
+        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
+        'myfile2.txt': {lines_inserted: 10},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 10,
+        deleted: 5,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('_formatBytes function', () => {
+      const table = {
+        '64': '+64 B',
+        '1023': '+1023 B',
+        '1024': '+1 KiB',
+        '4096': '+4 KiB',
+        '1073741824': '+1 GiB',
+        '-64': '-64 B',
+        '-1023': '-1023 B',
+        '-1024': '-1 KiB',
+        '-4096': '-4 KiB',
+        '-1073741824': '-1 GiB',
+        '0': '+/-0 B',
+      };
+
+      for (const bytes in table) {
+        if (table.hasOwnProperty(bytes)) {
+          assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
+        }
+      }
+    });
+
+    test('_formatPercentage function', () => {
+      const table = [
+        {size: 100,
+          delta: 100,
+          display: '',
+        },
+        {size: 195060,
+          delta: 64,
+          display: '(+0%)',
+        },
+        {size: 195060,
+          delta: -64,
+          display: '(-0%)',
+        },
+        {size: 394892,
+          delta: -7128,
+          display: '(-2%)',
+        },
+        {size: 90,
+          delta: -10,
+          display: '(-10%)',
+        },
+        {size: 110,
+          delta: 10,
+          display: '(+10%)',
+        },
+      ];
+
+      for (const item of table) {
+        assert.equal(element._formatPercentage(
+            item.size, item.delta), item.display);
+      }
+    });
+
+    test('comment filtering', () => {
+      const comments = {
+        '/COMMIT_MSG': [
+          {
+            patch_set: 1,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49',
+            id: '1',
+          },
+          {
+            patch_set: 1,
+            message: 'oh hay',
+            updated: '2017-02-09 16:40:49',
+            id: '2',
+          },
+          {
+            patch_set: 2,
+            message: 'hello',
+            updated: '2017-02-10 16:40:49',
+            id: '3',
+          },
+        ],
+        'myfile.txt': [
+          {
+            patch_set: 1,
+            message: 'good news!',
+            updated: '2017-02-08 16:40:49',
+            id: '4',
+          },
+          {
+            patch_set: 2,
+            message: 'wat!?',
+            updated: '2017-02-09 16:40:49',
+            id: '5',
+          },
+          {
+            patch_set: 2,
+            message: 'hi',
+            updated: '2017-02-10 16:40:49',
+            id: '6',
+          },
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 2,
+            message: 'wat!?',
+            updated: '2017-02-09 16:40:49',
+            id: '7',
+            unresolved: true,
+          },
+          {
+            patch_set: 2,
+            message: 'hi',
+            updated: '2017-02-10 16:40:49',
+            id: '8',
+            in_reply_to: '7',
+            unresolved: false,
+          },
+          {
+            patch_set: 2,
+            message: 'good news!',
+            updated: '2017-02-08 16:40:49',
+            id: '9',
+            unresolved: true,
+          },
+        ],
+      };
+      const drafts = {
+        '/COMMIT_MSG': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-15 16:40:49',
+            id: '10',
+            unresolved: true,
+          },
+          {
+            patch_set: 1,
+            message: 'fyi',
+            updated: '2017-02-15 16:40:49',
+            id: '11',
+            unresolved: false,
+          },
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-11 16:40:49',
+            id: '12',
+            unresolved: false,
+          },
+        ],
+      };
+      element.changeComments = new ChangeComments(comments, {}, drafts, 123);
+
+      const parentTo1 = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+
+      const parentTo2 = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+
+      const _1To2 = {
+        basePatchNum: 1,
+        patchNum: 2,
+      };
+
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
+              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, parentTo1
+              , '/COMMIT_MSG'), '2c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2
+              , '/COMMIT_MSG'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              'unresolved.file'), '1 draft');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              'unresolved.file'), '1 draft');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'unresolved.file'), '1d');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'unresolved.file'), '1d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
+              'myfile.txt', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'myfile.txt', 'comment'), '3 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo1,
+              'myfile.txt'
+          ), '1c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo1,
+              'file_added_in_rev2.txt'
+          ), '');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              '/COMMIT_MSG', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo2,
+              '/COMMIT_MSG'
+          ), '1c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              '/COMMIT_MSG'), '2 drafts');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '2 drafts');
+      assert.equal(
+          element._computeDraftsStringMobile(
+              element.changeComments,
+              parentTo1,
+              '/COMMIT_MSG'
+          ), '2d');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '2d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'myfile.txt', 'comment'), '2 comments');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'myfile.txt', 'comment'), '3 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo2,
+              'myfile.txt'
+          ), '2c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '3c');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'unresolved.file', 'comment'), '2 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'unresolved.file', 'comment'), '2 comments (1 unresolved)');
+    });
+
+    test('_reviewedTitle', () => {
+      assert.equal(
+          element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');
+
+      assert.equal(
+          element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
+    });
+
+    suite('keyboard shortcuts', () => {
+      setup(() => {
+        element._filesByPath = {
+          '/COMMIT_MSG': {},
+          'file_added_in_rev2.txt': {},
+          'myfile.txt': {},
+        };
+        element.changeNum = '42';
+        element.patchRange = {
+          basePatchNum: 'PARENT',
+          patchNum: 2,
+        };
+        element.change = {_number: 42};
+        element.$.fileCursor.setCursorAtIndex(0);
+      });
+
+      test('toggle left diff via shortcut', () => {
+        const toggleLeftDiffStub = sinon.stub();
+        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
+        // https://github.com/sinonjs/sinon/issues/781
+        const diffsStub = sinon.stub(element, 'diffs')
+            .get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+        assert.isTrue(toggleLeftDiffStub.calledOnce);
+        diffsStub.restore();
+      });
+
+      test('keyboard shortcuts', () => {
+        flush();
+
+        const items = [...element.root.querySelectorAll('.file-row')];
+        element.$.fileCursor.stops = items;
+        element.$.fileCursor.setCursorAtIndex(0);
+        assert.equal(items.length, 3);
+        assert.isTrue(items[0].classList.contains('selected'));
+        assert.isFalse(items[1].classList.contains('selected'));
+        assert.isFalse(items[2].classList.contains('selected'));
+        // j with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
+        assert.equal(element.$.fileCursor.index, 0);
+        // down should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
+        assert.equal(element.$.fileCursor.index, 0);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+
+        const navStub = sinon.stub(GerritNav, 'navigateToDiff');
+        assert.equal(element.$.fileCursor.index, 2);
+        assert.equal(element.selectedIndex, 2);
+
+        // k with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
+        assert.equal(element.$.fileCursor.index, 2);
+
+        // up should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
+        assert.equal(element.$.fileCursor.index, 2);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
+
+        assert(navStub.lastCall.calledWith(element.change,
+            'file_added_in_rev2.txt', 2),
+        'Should navigate to /c/42/2/file_added_in_rev2.txt');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 0);
+        assert.equal(element.selectedIndex, 0);
+
+        const createCommentInPlaceStub = sinon.stub(element.$.diffCursor,
+            'createCommentInPlace');
+        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
+        assert.isTrue(createCommentInPlaceStub.called);
+      });
+
+      test('i key shows/hides selected inline diff', () => {
+        const paths = Object.keys(element._filesByPath);
+        sinon.stub(element, '_expandedFilesChanged');
+        flush();
+        const files = [...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');
+        flush();
+        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');
+        flush();
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
+
+        element.$.fileCursor.setCursorAtIndex(1);
+        MockInteractions.keyUpOn(element, 73, null, 'i');
+        flush();
+        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');
+        flush();
+        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.isTrue(
+              element._expandedFiles
+                  .some(f => f.path === element.diffs[index].path)
+          );
+        }
+
+        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        flush();
+        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 getNumReviewed = () => element._files.reduce(reducer, 0);
+        flush();
+
+        // Default state should be unreviewed.
+        assert.equal(getNumReviewed(), 0);
+
+        // Press the review key to toggle it (set the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        flush();
+        assert.equal(getNumReviewed(), 1);
+
+        // Press the review key to toggle it (clear the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.equal(getNumReviewed(), 0);
+      });
+
+      suite('_handleOpenFile', () => {
+        let interact;
+
+        setup(() => {
+          sinon.stub(element, 'shouldSuppressKeyboardShortcut')
+              .returns(false);
+          sinon.stub(element, 'modifierPressed').returns(false);
+          const openCursorStub = sinon.stub(element, '_openCursorFile');
+          const openSelectedStub = sinon.stub(element, '_openSelectedFile');
+          const expandStub = sinon.stub(element, '_toggleFileExpanded');
+
+          interact = function(opt_payload) {
+            openCursorStub.reset();
+            openSelectedStub.reset();
+            expandStub.reset();
+
+            const e = new CustomEvent('fake-keyboard-event', opt_payload);
+            sinon.stub(e, 'preventDefault');
+            element._handleOpenFile(e);
+            assert.isTrue(e.preventDefault.called);
+            const result = {};
+            if (openCursorStub.called) {
+              result.opened_cursor = true;
+            }
+            if (openSelectedStub.called) {
+              result.opened_selected = true;
+            }
+            if (expandStub.called) {
+              result.expanded = true;
+            }
+            return result;
+          };
+        });
+
+        test('open from selected file', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+
+        test('open from diff cursor', () => {
+          element._showInlineDiffs = true;
+          assert.deepEqual(interact(), {opened_cursor: true});
+        });
+
+        test('expand when user prefers', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+          element._userPrefs = {};
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+      });
+
+      test('shift+left/shift+right', () => {
+        const moveLeftStub = sinon.stub(element.$.diffCursor, 'moveLeft');
+        const moveRightStub = sinon.stub(element.$.diffCursor, 'moveRight');
+
+        let noDiffsExpanded = true;
+        sinon.stub(element, '_noDiffsExpanded')
+            .callsFake(() => noDiffsExpanded);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        assert.isFalse(moveLeftStub.called);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        assert.isFalse(moveRightStub.called);
+
+        noDiffsExpanded = false;
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        assert.isTrue(moveLeftStub.called);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        assert.isTrue(moveRightStub.called);
+      });
+    });
+
+    test('computed properties', () => {
+      assert.equal(element._computeFileStatus('A'), 'A');
+      assert.equal(element._computeFileStatus(undefined), 'M');
+      assert.equal(element._computeFileStatus(null), 'M');
+
+      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
+      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
+          'clazz invisible');
+    });
+
+    test('file review status', () => {
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'file_added_in_rev2.txt': {},
+        'myfile.txt': {},
+      };
+      element._loggedIn = true;
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element.$.fileCursor.setCursorAtIndex(0);
+      const reviewSpy = sinon.spy(element, '_reviewFile');
+      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+
+      flush();
+      const fileRows =
+          element.root.querySelectorAll('.row:not(.header-row)');
+      const checkSelector = 'span.reviewedSwitch[role="switch"]';
+      const commitMsg = fileRows[0].querySelector(checkSelector);
+      const fileAdded = fileRows[1].querySelector(checkSelector);
+      const myFile = fileRows[2].querySelector(checkSelector);
+
+      assert.equal(commitMsg.getAttribute('aria-checked'), 'true');
+      assert.equal(fileAdded.getAttribute('aria-checked'), 'false');
+      assert.equal(myFile.getAttribute('aria-checked'), 'true');
+
+      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
+      const markReviewLabel = fileRows[0].querySelector('.markReviewed');
+      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
+
+      const clickSpy = sinon.spy(element, '_reviewedClick');
+      MockInteractions.tap(markReviewLabel);
+      // assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
+      // assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledOnce);
+
+      MockInteractions.tap(markReviewLabel);
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
+      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledTwice);
+
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
+    test('_computeFileStatusLabel', () => {
+      assert.equal(element._computeFileStatusLabel('A'), 'Added');
+      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
+    });
+
+    test('_handleFileListClick', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'f1.txt': {},
+        'f2.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+
+      const clickSpy = sinon.spy(element, '_handleFileListClick');
+      const reviewStub = sinon.stub(element, '_reviewFile');
+      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+
+      const row = dom(element.root)
+          .querySelector(`.row[data-file='{"path":"f1.txt"}']`);
+
+      // 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);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      assert.isFalse(reviewStub.called);
+
+      // Click inside the diff. This should result in no additional calls to
+      // _toggleFileExpanded or _reviewFile.
+      element.root.querySelector('gr-diff-host')
+          .click();
+      assert.isTrue(clickSpy.calledTwice);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      assert.isFalse(reviewStub.called);
+    });
+
+    test('_handleFileListClick editMode', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'f1.txt': {},
+        'f2.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element.editMode = true;
+      flush();
+      const clickSpy = sinon.spy(element, '_handleFileListClick');
+      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+
+      // Tap the edit controls. Should be ignored by _handleFileListClick.
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.editFileControls'));
+      assert.isTrue(clickSpy.calledOnce);
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
+    test('checkbox shows/hides diff inline', () => {
+      element._filesByPath = {
+        'myfile.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element.$.fileCursor.setCursorAtIndex(0);
+      sinon.stub(element, '_expandedFilesChanged');
+      flush();
+      const fileRows =
+          element.root.querySelectorAll('.row:not(.header-row)');
+      // Because the label surrounds the input, the tap event is triggered
+      // there first.
+      const showHideCheck = fileRows[0].querySelector(
+          'span.show-hide[role="switch"]');
+      const showHideLabel = showHideCheck.querySelector('.show-hide-icon');
+      assert.equal(showHideCheck.getAttribute('aria-checked'), 'false');
+      MockInteractions.tap(showHideLabel);
+      assert.equal(showHideCheck.getAttribute('aria-checked'), 'true');
+      assert.notEqual(
+          element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
+          -1);
+    });
+
+    test('diff mode correctly toggles the diffs', () => {
+      element._filesByPath = {
+        'myfile.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      sinon.spy(element, '_updateDiffPreferences');
+      element.$.fileCursor.setCursorAtIndex(0);
+      flush();
+
+      // Tap on a file to generate the diff.
+      const row = dom(element.root)
+          .querySelectorAll('.row:not(.header-row) span.show-hide')[0];
+
+      MockInteractions.tap(row);
+      flush();
+      const diffDisplay = element.diffs[0];
+      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+      element.set('diffViewMode', 'UNIFIED_DIFF');
+      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
+      assert.isTrue(element._updateDiffPreferences.called);
+    });
+
+    test('expanded attribute not set on path when not expanded', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+      };
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.expanded'));
+    });
+
+    test('tapping row ignores links', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      sinon.stub(element, '_expandedFilesChanged');
+      flush();
+      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 = sinon.spy(element, '_toggleFileExpanded');
+
+      MockInteractions.tap(commitMsgFile);
+      flush();
+      assert(togglePathSpy.notCalled, 'file is opened as diff view');
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.expanded'));
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.show-hide')).display,
+      'none');
+    });
+
+    test('_toggleFileExpanded', () => {
+      const path = 'path/to/my/file.txt';
+      element._filesByPath = {[path]: {}};
+      const renderSpy = sinon.spy(element, '_renderInOrder');
+      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
+
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
+      assert.equal(element._expandedFiles.length, 0);
+      element._toggleFileExpanded({path});
+      flush();
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-less');
+
+      assert.equal(renderSpy.callCount, 1);
+      assert.isTrue(element._expandedFiles.some(f => f.path === path));
+      element._toggleFileExpanded({path});
+      flush();
+
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
+      assert.equal(renderSpy.callCount, 1);
+      assert.isFalse(element._expandedFiles.some(f => f.path === path));
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
+    });
+
+    test('expandAllDiffs and collapseAllDiffs', () => {
+      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
+      const cursorUpdateStub = sinon.stub(element.$.diffCursor,
+          'handleDiffUpdate');
+      const reInitStub = sinon.stub(element.$.diffCursor,
+          'reInitAndUpdateStops');
+
+      const path = 'path/to/my/file.txt';
+      element._filesByPath = {[path]: {}};
+      element.expandAllDiffs();
+      flush();
+      assert.isTrue(element._showInlineDiffs);
+      assert.isTrue(reInitStub.calledOnce);
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+
+      element.collapseAllDiffs();
+      flush();
+      assert.equal(element._expandedFiles.length, 0);
+      assert.isFalse(element._showInlineDiffs);
+      assert.isTrue(cursorUpdateStub.calledOnce);
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
+    });
+
+    test('_expandedFilesChanged', done => {
+      sinon.stub(element, '_reviewFile');
+      const path = 'path/to/my/file.txt';
+      const diffs = [{
+        path,
+        style: {},
+        reload() {
+          done();
+        },
+        prefetchDiff() {},
+        cancel() {},
+        getCursorStops() { return []; },
+        addEventListener(eventName, callback) {
+          if (['render-start', 'render-content', 'scroll']
+              .indexOf(eventName) >= 0) {
+            callback(new Event(eventName));
+          }
+        },
+      }];
+      sinon.stub(element, 'diffs').get(() => diffs);
+      element.push('_expandedFiles', {path});
+    });
+
+    test('_clearCollapsedDiffs', () => {
+      const diff = {
+        cancel: sinon.stub(),
+        clearDiffContent: sinon.stub(),
+      };
+      element._clearCollapsedDiffs([diff]);
+      assert.isTrue(diff.cancel.calledOnce);
+      assert.isTrue(diff.clearDiffContent.calledOnce);
+    });
+
+    test('filesExpanded value updates to correct enum', () => {
+      element._filesByPath = {
+        'foo.bar': {},
+        'baz.bar': {},
+      };
+      flush();
+      assert.equal(element.filesExpanded,
+          FilesExpandedState.NONE);
+      element.push('_expandedFiles', {path: 'baz.bar'});
+      flush();
+      assert.equal(element.filesExpanded,
+          FilesExpandedState.SOME);
+      element.push('_expandedFiles', {path: 'foo.bar'});
+      flush();
+      assert.equal(element.filesExpanded,
+          FilesExpandedState.ALL);
+      element.collapseAllDiffs();
+      flush();
+      assert.equal(element.filesExpanded,
+          FilesExpandedState.NONE);
+      element.expandAllDiffs();
+      flush();
+      assert.equal(element.filesExpanded,
+          FilesExpandedState.ALL);
+    });
+
+    test('_renderInOrder', done => {
+      const reviewStub = sinon.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
+        path: 'p0',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(callCount++, 2);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p1',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(callCount++, 1);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p2',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(callCount++, 0);
+          return Promise.resolve();
+        },
+      }];
+      element._renderInOrder([
+        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
+      ], diffs, 3)
+          .then(() => {
+            assert.isFalse(reviewStub.called);
+            assert.isTrue(loadCommentSpy.called);
+            done();
+          });
+    });
+
+    test('_renderInOrder logged in', done => {
+      element._loggedIn = true;
+      const reviewStub = sinon.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
+        path: 'p0',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(reviewStub.callCount, 2);
+          assert.equal(callCount++, 2);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p1',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(reviewStub.callCount, 1);
+          assert.equal(callCount++, 1);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p2',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(reviewStub.callCount, 0);
+          assert.equal(callCount++, 0);
+          return Promise.resolve();
+        },
+      }];
+      element._renderInOrder([
+        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
+      ], diffs, 3)
+          .then(() => {
+            assert.equal(reviewStub.callCount, 3);
+            done();
+          });
+    });
+
+    test('_renderInOrder respects diffPrefs.manual_review', () => {
+      element._loggedIn = true;
+      element.diffPrefs = {manual_review: true};
+      const reviewStub = sinon.stub(element, '_reviewFile');
+      const diffs = [{
+        path: 'p',
+        style: {},
+        prefetchDiff() {},
+        reload() { return Promise.resolve(); },
+      }];
+
+      return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
+        assert.isFalse(reviewStub.called);
+        delete element.diffPrefs.manual_review;
+        return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
+          assert.isTrue(reviewStub.called);
+          assert.isTrue(reviewStub.calledWithExactly('p', true));
+        });
+      });
+    });
+
+    test('_loadingChanged fired from reload in debouncer', done => {
+      sinon.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+      element.changeNum = 123;
+      element.patchRange = {patchNum: 12};
+      element._filesByPath = {'foo.bar': {}};
+
+      element.reload().then(() => {
+        assert.isFalse(element._loading);
+        element.flushDebouncer('loading-change');
+        assert.isFalse(element.classList.contains('loading'));
+        done();
+      });
+      assert.isTrue(element._loading);
+      assert.isFalse(element.classList.contains('loading'));
+      element.flushDebouncer('loading-change');
+      assert.isTrue(element.classList.contains('loading'));
+    });
+
+    test('_loadingChanged does not set class when there are no files', () => {
+      sinon.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+      element.changeNum = 123;
+      element.patchRange = {patchNum: 12};
+      element.reload();
+      assert.isTrue(element._loading);
+      element.flushDebouncer('loading-change');
+      assert.isFalse(element.classList.contains('loading'));
+    });
+  });
+
+  suite('diff url file list', () => {
+    test('diff url', () => {
+      const diffStub = sinon.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();
+    });
+
+    test('diff url commit msg', () => {
+      const diffStub = sinon.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 = sinon.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 = sinon.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', () => {
+      const defaultSizeBarLayout = {
+        maxInserted: 0,
+        maxDeleted: 0,
+        maxAdditionWidth: 0,
+        maxDeletionWidth: 0,
+        deletionOffset: 0,
+      };
+
+      assert.deepEqual(
+          element._computeSizeBarLayout(null),
+          defaultSizeBarLayout);
+      assert.deepEqual(
+          element._computeSizeBarLayout({}),
+          defaultSizeBarLayout);
+      assert.deepEqual(
+          element._computeSizeBarLayout({base: []}),
+          defaultSizeBarLayout);
+
+      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('_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,
+      };
+
+      // Uses half the space when file is half the largest addition and there
+      // are no deletions.
+      assert.equal(element._computeBarAdditionWidth(file, stats), 30);
+
+      // If there are no insetions, there is no width.
+      stats.maxInserted = 0;
+      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);
+
+      // If the file is a commit message, returns zero.
+      file.lines_inserted = 5;
+      file.__path = '/COMMIT_MSG';
+      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);
+    });
+
+    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);
+    });
+
+    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,
+      };
+
+      // 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 there are no deletions, there is no width.
+      stats.maxDeleted = 0;
+      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);
+
+      // If the file is a commit message, returns zero.
+      file.lines_deleted = 5;
+      file.__path = '/COMMIT_MSG';
+      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);
+    });
+
+    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 ');
+    });
+  });
+
+  suite('gr-file-list inline diff tests', () => {
+    let element;
+
+    const commitMsgComments = [
+      {
+        patch_set: 2,
+        id: 'ecf0b9fa_fe1a5f62',
+        line: 20,
+        updated: '2018-02-08 18:49:18.000000000',
+        message: 'another comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2,
+        id: '503008e2_0ab203ee',
+        line: 10,
+        updated: '2018-02-14 22:07:43.000000000',
+        message: 'a comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2,
+        id: 'cc788d2c_cb1d728c',
+        line: 20,
+        in_reply_to: 'ecf0b9fa_fe1a5f62',
+        updated: '2018-02-13 22:07:43.000000000',
+        message: 'response',
+        unresolved: true,
+      },
+    ];
+
+    async function setupDiff(diff) {
+      diff.comments = {
+        left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
+        right: [],
+        meta: {
+          changeNum: 1,
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 2,
+          },
+        },
+      };
+      diff.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',
+      };
+      diff.diff = getMockDiffResponse();
+      commentApiWrapper.loadComments().then(() => {
+        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
+            .withArgs('/COMMIT_MSG', {
+              basePatchNum: 'PARENT',
+              patchNum: 2,
+            })
+            .returns(diff.comments);
+      });
+      await listenOnce(diff, 'render');
+    }
+
+    async function renderAndGetNewDiffs(index) {
+      const diffs =
+          element.root.querySelectorAll('gr-diff-host');
+
+      for (let i = index; i < diffs.length; i++) {
+        await setupDiff(diffs[i]);
+      }
+
+      element._updateDiffCursor();
+      element.$.diffCursor.handleDiffUpdate();
+      return diffs;
+    }
+
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat() { return Promise.resolve(''); },
+      });
+      stub('gr-diff-host', {
+        reload() { return Promise.resolve(); },
+        prefetchDiff() {},
+      });
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.fileList;
+      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.diffPrefs = {};
+      element.change = {_number: 42, project: 'testRepo'};
+      sinon.stub(element, '_reviewFile');
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      commentApiWrapper.loadComments().then(() => {
+        sinon.stub(element.changeComments, 'getPaths').returns({});
+        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
+            .returns({meta: {}, left: [], right: []});
+        done();
+      });
+      element._loading = false;
+      element.numFilesShown = 75;
+      element.selectedIndex = 0;
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      };
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._loggedIn = true;
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
+      flush();
+    });
+
+    test('cursor with individually opened files', async () => {
+      MockInteractions.keyUpOn(element, 73, null, 'i');
+      flush();
+      let diffs = await renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 1);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flush();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flush();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+      assert.isFalse(diffStops[11].classList.contains('target-row'));
+
+      // The file cursor is now at 1.
+      assert.equal(element.$.fileCursor.index, 1);
+      MockInteractions.keyUpOn(element, 73, null, 'i');
+      flush();
+
+      diffs = await renderAndGetNewDiffs(1);
+      // Two diffs should be rendered.
+      assert.equal(diffs.length, 2);
+      const diffStopsFirst = diffs[0].getCursorStops();
+      const diffStopsSecond = diffs[1].getCursorStops();
+
+      // The line on the first diff is still selected
+      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
+      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
+    });
+
+    test('cursor with toggle all files', async () => {
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      flush();
+
+      const diffs = await renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 3);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flush();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flush();
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+      assert.isTrue(diffStops[11].classList.contains('target-row'));
+
+      // The file cursor is still at 0.
+      assert.equal(element.$.fileCursor.index, 0);
+    });
+
+    suite('n key presses', () => {
+      let nKeySpy;
+      let nextCommentStub;
+      let nextChunkStub;
+      let fileRows;
+
+      setup(() => {
+        sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
+        nKeySpy = sinon.spy(element, '_handleNextChunk');
+        nextCommentStub = sinon.stub(element.$.diffCursor,
+            'moveToNextCommentThread');
+        nextChunkStub = sinon.stub(element.$.diffCursor,
+            'moveToNextChunk');
+        fileRows =
+            element.root.querySelectorAll('.row:not(.header-row)');
+      });
+
+      test('n key with some files expanded and no shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        flush();
+
+        // Handle N key should return before calling diff cursor functions.
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.equal(element.filesExpanded, 'some');
+      });
+
+      test('n key with some files expanded and shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        flush();
+        assert.equal(nextChunkStub.callCount, 0);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isTrue(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 0);
+        assert.equal(element.filesExpanded, 'some');
+      });
+
+      test('n key without all files expanded and shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        flush();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isTrue(element._showInlineDiffs);
+      });
+
+      test('n key without all files expanded and no shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        flush();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isTrue(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 0);
+        assert.isTrue(element._showInlineDiffs);
+      });
+    });
+
+    test('_openSelectedFile behavior', () => {
+      const _filesByPath = element._filesByPath;
+      element.set('_filesByPath', {});
+      const navStub = sinon.stub(GerritNav, 'navigateToDiff');
+      // Noop when there are no files.
+      element._openSelectedFile();
+      assert.isFalse(navStub.called);
+
+      element.set('_filesByPath', _filesByPath);
+      flush();
+      // Navigates when a file is selected.
+      element._openSelectedFile();
+      assert.isTrue(navStub.called);
+    });
+
+    test('_displayLine', () => {
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut')
+          .callsFake(() => false);
+      sinon.stub(element, 'modifierPressed')
+          .callsFake(() => false);
+      element._showInlineDiffs = true;
+      const mockEvent = {preventDefault() {}};
+
+      element._displayLine = false;
+      element._handleCursorNext(mockEvent);
+      assert.isTrue(element._displayLine);
+
+      element._displayLine = false;
+      element._handleCursorPrev(mockEvent);
+      assert.isTrue(element._displayLine);
+
+      element._displayLine = true;
+      element._handleEscKey(mockEvent);
+      assert.isFalse(element._displayLine);
+    });
+
+    suite('editMode behavior', () => {
+      test('reviewed checkbox', () => {
+        element._reviewFile.restore();
+        const saveReviewStub = sinon.stub(element, '_saveReviewedState');
+
+        element.editMode = false;
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.isTrue(saveReviewStub.calledOnce);
+
+        element.editMode = true;
+        flush();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.isTrue(saveReviewStub.calledOnce);
+      });
+
+      test('_getReviewedFiles does not call API', () => {
+        const apiSpy = sinon.spy(element.$.restAPI, 'getReviewedFiles');
+        element.editMode = true;
+        return element._getReviewedFiles().then(files => {
+          assert.equal(files.length, 0);
+          assert.isFalse(apiSpy.called);
+        });
+      });
+    });
+
+    test('editing actions', () => {
+      // Edit controls are guarded behind a dom-if initially and not rendered.
+      assert.isNotOk(dom(element.root)
+          .querySelector('gr-edit-file-controls'));
+
+      element.editMode = true;
+      flush();
+
+      // Commit message should not have edit controls.
+      const editControls =
+          Array.from(
+              dom(element.root)
+                  .querySelectorAll('.row:not(.header-row)'))
+              .map(row => row.querySelector('gr-edit-file-controls'));
+      assert.isTrue(editControls[0].classList.contains('invisible'));
+    });
+
+    test('reloadCommentsForThreadWithRootId', async () => {
+      // Expand the commit message diff
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      const diffs = await renderAndGetNewDiffs(0);
+      flush();
+
+      // Two comment threads should be generated by renderAndGetNewDiffs
+      const threadEls = diffs[0].getThreadEls();
+      assert.equal(threadEls.length, 2);
+      const threadElsByRootId = new Map(
+          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
+
+      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
+      assert.equal(thread1.comments.length, 1);
+      assert.equal(thread1.comments[0].message, 'a comment');
+      assert.equal(thread1.comments[0].line, 10);
+
+      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
+      assert.equal(thread2.comments.length, 2);
+      assert.isTrue(thread2.comments[0].unresolved);
+      assert.equal(thread2.comments[0].message, 'another comment');
+      assert.equal(thread2.comments[0].line, 20);
+
+      const commentStub =
+          sinon.stub(element.changeComments, 'getCommentsForThread');
+      const commentStubRes1 = [
+        {
+          patch_set: 2,
+          id: '503008e2_0ab203ee',
+          line: 20,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'edited text',
+          unresolved: false,
+        },
+      ];
+      const commentStubRes2 = [
+        {
+          patch_set: 2,
+          id: 'ecf0b9fa_fe1a5f62',
+          line: 20,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'another comment',
+          unresolved: true,
+        },
+        {
+          patch_set: 2,
+          id: '503008e2_0ab203ee',
+          line: 10,
+          in_reply_to: 'ecf0b9fa_fe1a5f62',
+          updated: '2018-02-14 22:07:43.000000000',
+          message: 'response',
+          unresolved: true,
+        },
+        {
+          patch_set: 2,
+          id: '503008e2_0ab203ef',
+          line: 20,
+          in_reply_to: '503008e2_0ab203ee',
+          updated: '2018-02-15 22:07:43.000000000',
+          message: 'a third comment in the thread',
+          unresolved: true,
+        },
+      ];
+      commentStub.withArgs('503008e2_0ab203ee').returns(
+          commentStubRes1);
+      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
+          commentStubRes2);
+
+      // Reload comments from the first comment thread, which should have a
+      // an updated message and a toggled resolve state.
+      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
+          '/COMMIT_MSG');
+      assert.equal(thread1.comments.length, 1);
+      assert.isFalse(thread1.comments[0].unresolved);
+      assert.equal(thread1.comments[0].message, 'edited text');
+
+      // Reload comments from the second comment thread, which should have a new
+      // reply.
+      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
+          '/COMMIT_MSG');
+      assert.equal(thread2.comments.length, 3);
+
+      const commentStubCount = commentStub.callCount;
+      const getThreadsSpy = sinon.spy(diffs[0], 'getThreadEls');
+
+      // Should not be getting threads when the file is not expanded.
+      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
+          'other/file');
+      assert.isFalse(getThreadsSpy.called);
+      assert.equal(commentStubCount, commentStub.callCount);
+
+      // Should be query selecting diffs when the file is expanded.
+      // Should not be fetching change comments when the rootId is not found
+      // to match.
+      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
+          '/COMMIT_MSG');
+      assert.isTrue(getThreadsSpy.called);
+      assert.equal(commentStubCount, commentStub.callCount);
+    });
+  });
+});
+
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
deleted file mode 100644
index bd262ec..0000000
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
+++ /dev/null
@@ -1,114 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrIncludedInDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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',
-      },
-      /** @type {?} */
-      _includedIn: Object,
-      _loaded: {
-        type: Boolean,
-        value: false,
-      },
-      _filterText: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  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;
-  }
-
-  _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);
-  }
-
-  _handleCloseTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('close', {
-      composed: true, 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.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
new file mode 100644
index 0000000..1957f5c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -0,0 +1,129 @@
+/**
+ * @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.
+ */
+import '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-included-in-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {IncludedInInfo, NumericChangeId} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrIncludedInDialog {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+interface DisplayGroup {
+  title: string;
+  items: string[];
+}
+
+@customElement('gr-included-in-dialog')
+export class GrIncludedInDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the user presses the close button.
+   *
+   * @event close
+   */
+
+  @property({type: Object, observer: '_resetData'})
+  changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  _includedIn?: IncludedInInfo;
+
+  @property({type: Boolean})
+  _loaded = false;
+
+  @property({type: String})
+  _filterText = '';
+
+  loadData() {
+    if (!this.changeNum) {
+      return Promise.reject(new Error('missing required property changeNum'));
+    }
+    this._filterText = '';
+    return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(configs => {
+      if (!configs) {
+        return;
+      }
+      this._includedIn = configs;
+      this._loaded = true;
+    });
+  }
+
+  _resetData() {
+    this._includedIn = undefined;
+    this._loaded = false;
+  }
+
+  _computeGroups(includedIn: IncludedInInfo | undefined, filterText: string) {
+    if (!includedIn || filterText === undefined) {
+      return [];
+    }
+
+    const filter = (item: string) =>
+      !filterText.length ||
+      item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+
+    const groups: DisplayGroup[] = [
+      {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);
+  }
+
+  _handleCloseTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('close', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _computeLoadingClass(loaded: boolean) {
+    return loaded ? 'loading loaded' : 'loading';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-included-in-dialog': 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
deleted file mode 100644
index 2faac52..0000000
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
new file mode 100644
index 0000000..8e90f3b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
@@ -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';
+
+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" class="heading-1">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
deleted file mode 100644
index 5d5b1fc..0000000
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
+++ /dev/null
@@ -1,100 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-included-in-dialog></gr-included-in-dialog>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  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');
-    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: ['stable-3.2']},
-      ]);
-
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.js
new file mode 100644
index 0000000..c109538
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.js
@@ -0,0 +1,82 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-included-in-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-included-in-dialog');
+
+suite('gr-included-in-dialog', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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');
+    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: ['stable-3.2']},
+      ]);
+
+      done();
+    });
+  });
+});
+
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
deleted file mode 100644
index 8541840..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ /dev/null
@@ -1,189 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 {
-    /**
-     * @type {{ name: string }}
-     */
-      label: Object,
-      labels: Object,
-      name: {
-        type: String,
-        reflectToAttribute: true,
-      },
-      permittedLabels: Object,
-      labelValues: Object,
-      _selectedValueText: {
-        type: String,
-        value: 'No value selected',
-      },
-      _items: {
-        type: Array,
-        computed: '_computePermittedLabelValues(permittedLabels, label.name)',
-      },
-    };
-  }
-
-  get selectedItem() {
-    if (!this._ironSelector) { return undefined; }
-    return this._ironSelector.selectedItem;
-  }
-
-  get selectedValue() {
-    if (!this._ironSelector) { return undefined; }
-    return this._ironSelector.selected;
-  }
-
-  setSelectedValue(value) {
-    // The selector may not be present if it’s not at the latest patch set.
-    if (!this._ironSelector) { return; }
-    this._ironSelector.select(value);
-  }
-
-  get _ironSelector() {
-    return this.$ && this.$.labelSelector;
-  }
-
-  _computeBlankItems(permittedLabels, label, side) {
-    if (!permittedLabels || !permittedLabels[label] ||
-        !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);
-    }
-  }
-
-  /**
-   * 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';
-    }
-  }
-
-  _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;
-      }
-    }
-    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.dataset.name;
-    const value = e.target.selectedItem.dataset.value;
-    this.dispatchEvent(new CustomEvent(
-        'labels-changed',
-        {detail: {name, value}, bubbles: true, composed: true}));
-  }
-
-  _computeAnyPermittedLabelValues(permittedLabels, label) {
-    return permittedLabels && permittedLabels.hasOwnProperty(label) &&
-      permittedLabels[label].length;
-  }
-
-  _computeHiddenClass(permittedLabels, label) {
-    return !this._computeAnyPermittedLabelValues(permittedLabels, label) ?
-      'hidden' : '';
-  }
-
-  _computePermittedLabelValues(permittedLabels, label) {
-    // Polymer 2: check for undefined
-    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.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
new file mode 100644
index 0000000..60a6058
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -0,0 +1,289 @@
+/**
+ * @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-selector/iron-selector';
+import '../../shared/gr-button/gr-button';
+import '../../../styles/gr-voting-styles';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-label-score-row_html';
+import {customElement, property} from '@polymer/decorators';
+import {IronSelectorElement} from '@polymer/iron-selector/iron-selector';
+import {
+  LabelNameToValueMap,
+  LabelNameToInfoMap,
+  QuickLabelInfo,
+  DetailedLabelInfo,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+export interface Label {
+  name: string;
+  value: string | null;
+}
+
+// TODO(TS): add description to explain what this is after moving
+// gr-label-scores to ts
+export interface LabelValuesMap {
+  [key: number]: number;
+}
+
+export interface GrLabelScoreRow {
+  $: {
+    labelSelector: IronSelectorElement;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-label-score-row': GrLabelScoreRow;
+  }
+}
+
+@customElement('gr-label-score-row')
+export class GrLabelScoreRow extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when any label is changed.
+   *
+   * @event labels-changed
+   */
+
+  @property({type: Object})
+  label: Label | undefined | null;
+
+  @property({type: Object})
+  labels?: LabelNameToInfoMap;
+
+  @property({type: String, reflectToAttribute: true})
+  name?: string;
+
+  @property({type: Object})
+  permittedLabels: LabelNameToValueMap | undefined | null;
+
+  @property({type: Object})
+  labelValues?: LabelValuesMap;
+
+  @property({type: String})
+  _selectedValueText = 'No value selected';
+
+  @property({
+    computed: '_computePermittedLabelValues(permittedLabels, label.name)',
+    type: Array,
+  })
+  _items!: string[];
+
+  get selectedItem() {
+    if (!this._ironSelector) {
+      return undefined;
+    }
+    return this._ironSelector.selectedItem;
+  }
+
+  get selectedValue() {
+    if (!this._ironSelector) {
+      return undefined;
+    }
+    return this._ironSelector.selected;
+  }
+
+  setSelectedValue(value: string) {
+    // The selector may not be present if it’s not at the latest patch set.
+    if (!this._ironSelector) {
+      return;
+    }
+    this._ironSelector.select(value);
+  }
+
+  get _ironSelector() {
+    return this.$ && this.$.labelSelector;
+  }
+
+  _computeBlankItems(
+    permittedLabels: LabelNameToValueMap,
+    label: string,
+    side: string
+  ) {
+    if (
+      !permittedLabels ||
+      !permittedLabels[label] ||
+      !permittedLabels[label].length ||
+      !this.labelValues ||
+      !Object.keys(this.labelValues).length
+    ) {
+      return [];
+    }
+    const startPosition = this.labelValues[Number(permittedLabels[label][0])];
+    if (side === 'start') {
+      return new Array(startPosition);
+    }
+    const endPosition = this.labelValues[
+      Number(permittedLabels[label][permittedLabels[label].length - 1])
+    ];
+    return new Array(Object.keys(this.labelValues).length - endPosition - 1);
+  }
+
+  _getLabelValue(
+    labels: LabelNameToInfoMap,
+    permittedLabels: LabelNameToValueMap,
+    label: Label
+  ) {
+    if (label.value) {
+      return label.value;
+    } else if (
+      hasOwnProperty(labels[label.name], 'default_value') &&
+      hasOwnProperty(permittedLabels, label.name)
+    ) {
+      // default_value is an int, convert it to string label, e.g. "+1".
+      return permittedLabels[label.name].find(
+        value =>
+          Number(value) === (labels[label.name] as QuickLabelInfo).default_value
+      );
+    }
+    return;
+  }
+
+  /**
+   * 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: number, index: number, totalItems: number) {
+    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';
+    }
+  }
+
+  _computeLabelValue(
+    labels?: LabelNameToInfoMap,
+    permittedLabels?: LabelNameToValueMap,
+    label?: Label
+  ) {
+    // Polymer 2+ undefined check
+    if (
+      labels === undefined ||
+      permittedLabels === undefined ||
+      label === undefined
+    ) {
+      return null;
+    }
+
+    if (!labels[label.name]) {
+      return null;
+    }
+    const labelValue = this._getLabelValue(labels, permittedLabels, label);
+    const len = permittedLabels[label.name]
+      ? 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;
+  }
+
+  _setSelectedValueText(e: Event) {
+    // Needed because when the selected item changes, it first changes to
+    // nothing and then to the new item.
+    const selectedItem = (e.target as IronSelectorElement)
+      .selectedItem as HTMLElement;
+    if (!selectedItem) {
+      return;
+    }
+    if (!this.$.labelSelector.items) {
+      return;
+    }
+    for (const item of this.$.labelSelector.items) {
+      if (selectedItem === item) {
+        item.setAttribute('aria-checked', 'true');
+      } else {
+        item.removeAttribute('aria-checked');
+      }
+    }
+    this._selectedValueText = selectedItem.getAttribute('title') || '';
+    // Needed to update the style of the selected button.
+    this.updateStyles();
+    const name = selectedItem.dataset['name'];
+    const value = selectedItem.dataset['value'];
+    this.dispatchEvent(
+      new CustomEvent('labels-changed', {
+        detail: {name, value},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+
+  _computeAnyPermittedLabelValues(
+    permittedLabels: LabelNameToValueMap,
+    labelName: string
+  ) {
+    return (
+      permittedLabels &&
+      hasOwnProperty(permittedLabels, labelName) &&
+      permittedLabels[labelName].length
+    );
+  }
+
+  _computeHiddenClass(permittedLabels: LabelNameToValueMap, labelName: string) {
+    return !this._computeAnyPermittedLabelValues(permittedLabels, labelName)
+      ? 'hidden'
+      : '';
+  }
+
+  _computePermittedLabelValues(
+    permittedLabels?: LabelNameToValueMap,
+    labelName?: string
+  ) {
+    // Polymer 2: check for undefined
+    if (permittedLabels === undefined || labelName === undefined) {
+      return [];
+    }
+
+    return permittedLabels[labelName] || [];
+  }
+
+  _computeLabelValueTitle(
+    labels: LabelNameToInfoMap,
+    label: string,
+    value: string
+  ) {
+    // TODO(TS): maybe add a type guard for DetailedLabelInfo and QuickLabelInfo
+    return (
+      labels[label] &&
+      (labels[label] as DetailedLabelInfo).values &&
+      (labels[label] as DetailedLabelInfo).values![value]
+    );
+  }
+}
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
deleted file mode 100644
index 77148ad..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
new file mode 100644
index 0000000..312532e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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)
+        );
+        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" id="labelName" aria-hidden="true"
+    >[[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"
+      role="radiogroup"
+      aria-labelledby="labelName"
+    >
+      <template is="dom-repeat" items="[[_items]]" as="value">
+        <gr-button
+          role="radio"
+          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
deleted file mode 100644
index 6e0a90d..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
+++ /dev/null
@@ -1,372 +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">
-<meta charset="utf-8">
-<title>gr-label-score-row</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-label-score-row></gr-label-score-row>
-  </template>
-</test-fixture>
-
-<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,
-        }],
-      },
-      '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',
-        ],
-      };
-      flush(() => {
-        assert.strictEqual(element.$.labelSelector
-            .items.length, 5);
-        assert.strictEqual(
-            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);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
new file mode 100644
index 0000000..3c9b7c7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
@@ -0,0 +1,369 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-label-score-row.js';
+
+const basicFixture = fixtureFromElement('gr-label-score-row');
+
+suite('gr-label-row-score tests', () => {
+  let element;
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    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,
+        }],
+      },
+      '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);
+  });
+
+  function checkAriaCheckedValid() {
+    const items = element.$.labelSelector.items;
+    const selectedItem = element.selectedItem;
+    for (let i = 0; i < items.length; i++) {
+      const item = items[i];
+      if (items[i] === selectedItem) {
+        assert.isTrue(item.hasAttribute('aria-checked'), `item ${i}`);
+        assert.equal(item.getAttribute('aria-checked'), 'true', `item ${i}`);
+      } else {
+        assert.isFalse(item.hasAttribute('aria-checked'), `item ${i}`);
+      }
+    }
+  }
+
+  test('label picker', () => {
+    const labelsChangedHandler = sinon.stub();
+    element.addEventListener('labels-changed', labelsChangedHandler);
+    assert.ok(element.$.labelSelector);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector(
+            'gr-button[data-value="-1"]'));
+    flush();
+    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');
+    checkAriaCheckedValid();
+  });
+
+  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');
+    checkAriaCheckedValid();
+  });
+
+  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'});
+    flush();
+    assert.strictEqual(selector.selected, ' 0');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'No score');
+    checkAriaCheckedValid();
+  });
+
+  test('without permitted labels', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    flush();
+    assert.isOk(element.$.labelSelector);
+    assert.isFalse(element.$.labelSelector.hidden);
+
+    element.permittedLabels = {};
+    flush();
+    assert.isOk(element.$.labelSelector);
+    assert.isTrue(element.$.labelSelector.hidden);
+
+    element.permittedLabels = {Verified: []};
+    flush();
+    assert.isOk(element.$.labelSelector);
+    assert.isTrue(element.$.labelSelector.hidden);
+  });
+
+  test('asymmetrical labels', done => {
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        ' 0',
+        '+1',
+      ],
+    };
+    flush(() => {
+      assert.strictEqual(element.$.labelSelector
+          .items.length, 2);
+      assert.strictEqual(
+          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(
+            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,
+    };
+    flush();
+    assert.strictEqual(element.selectedValue, '-1');
+    checkAriaCheckedValid();
+  });
+
+  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,
+    };
+    flush();
+    assert.isNull(element.selectedValue);
+  });
+});
+
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
deleted file mode 100644
index 2d6825b..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
+++ /dev/null
@@ -1,156 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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)',
-      },
-      permittedLabels: {
-        type: Object,
-        observer: '_computeColumns',
-      },
-      /** @type {?} */
-      change: Object,
-      /** @type {?} */
-      account: Object,
-
-      _labelValues: Object,
-    };
-  }
-
-  getLabelValues() {
-    const labels = {};
-    for (const label in this.permittedLabels) {
-      if (!this.permittedLabels.hasOwnProperty(label)) { 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; }
-
-      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;
-      }
-    }
-    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 null;
-  }
-
-  _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;
-      }
-    }
-
-    const orderedValues = Object.keys(values).sort((a, b) => a - b);
-
-    for (let i = 0; i < orderedValues.length; i++) {
-      values[orderedValues[i]] = i;
-    }
-    this._labelValues = values;
-  }
-
-  _changeIsMerged(changeStatus) {
-    return changeStatus === 'MERGED';
-  }
-
-  /**
-   * @param {string|undefined} label
-   * @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.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
new file mode 100644
index 0000000..d528192
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -0,0 +1,230 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-label-score-row/gr-label-score-row';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-label-scores_html';
+import {customElement, property} from '@polymer/decorators';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  LabelNameToValueMap,
+  ChangeInfo,
+  AccountInfo,
+  DetailedLabelInfo,
+  LabelNameToInfoMap,
+  LabelNameToValuesMap,
+} from '../../../types/common';
+import {
+  GrLabelScoreRow,
+  LabelValuesMap,
+} from '../gr-label-score-row/gr-label-score-row';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+
+type Labels = {[label: string]: number};
+@customElement('gr-label-scores')
+export class GrLabelScores extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array, computed: '_computeLabels(change.labels.*, account)'})
+  _labels?: Labels;
+
+  @property({type: Object, observer: '_computeColumns'})
+  permittedLabels?: LabelNameToValueMap;
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Object})
+  _labelValues?: LabelValuesMap;
+
+  getLabelValues(includeDefaults = true): LabelNameToValuesMap {
+    const labels: LabelNameToValuesMap = {};
+    if (this.shadowRoot === null || !this.change) {
+      return labels;
+    }
+    for (const label in this.permittedLabels) {
+      if (!hasOwnProperty(this.permittedLabels, label)) {
+        continue;
+      }
+
+      const selectorEl = this.shadowRoot.querySelector(
+        `gr-label-score-row[name="${label}"]`
+      ) as null | GrLabelScoreRow;
+      if (!selectorEl) {
+        continue;
+      }
+
+      // The user may have not voted on this label.
+      if (!selectorEl.selectedItem) {
+        continue;
+      }
+
+      const selectedVal =
+        typeof selectorEl.selectedValue === 'string'
+          ? Number(selectorEl.selectedValue)
+          : selectorEl.selectedValue;
+
+      if (selectedVal === undefined) {
+        continue;
+      }
+
+      // Only send the selection if the user changed it.
+      const prevVal = this._getVoteForAccount(
+        this.change.labels,
+        label,
+        this.account
+      );
+
+      let prevValNum: number | null | undefined;
+      if (typeof prevVal === 'string') {
+        prevValNum = Number(prevVal);
+      } else {
+        prevValNum = prevVal;
+      }
+
+      const defValNum = this._getDefaultValue(this.change.labels, label);
+
+      if (selectedVal !== prevValNum) {
+        if (includeDefaults || !!prevValNum || selectedVal !== defValNum) {
+          labels[label] = selectedVal;
+        }
+      }
+    }
+    return labels;
+  }
+
+  _getStringLabelValue(
+    labels: LabelNameToInfoMap,
+    labelName: string,
+    numberValue?: number
+  ) {
+    for (const k in (labels[labelName] as DetailedLabelInfo).values) {
+      if (Number(k) === numberValue) {
+        return k;
+      }
+    }
+    return numberValue;
+  }
+
+  _getDefaultValue(labels?: LabelNameToInfoMap, labelName?: string) {
+    if (!labelName || !labels?.[labelName]) return undefined;
+    const labelInfo = labels[labelName] as DetailedLabelInfo;
+    return labelInfo.default_value;
+  }
+
+  _getVoteForAccount(
+    labels: LabelNameToInfoMap | undefined,
+    labelName: string,
+    account?: AccountInfo
+  ) {
+    if (!labels) return null;
+    const votes = labels[labelName] as DetailedLabelInfo;
+    if (votes.all && votes.all.length > 0) {
+      for (let i = 0; i < votes.all.length; i++) {
+        // TODO(TS): Replace == with === and check code can assign string to _account_id instead of number
+        // eslint-disable-next-line eqeqeq
+        if (account && votes.all[i]._account_id == account._account_id) {
+          return this._getStringLabelValue(
+            labels,
+            labelName,
+            votes.all[i].value
+          );
+        }
+      }
+    }
+    return null;
+  }
+
+  _computeLabels(
+    labelRecord: PolymerDeepPropertyChange<
+      LabelNameToInfoMap,
+      LabelNameToInfoMap
+    >,
+    account?: AccountInfo
+  ) {
+    // Polymer 2: check for undefined
+    if ([labelRecord, account].includes(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?: LabelNameToValueMap) {
+    if (!permittedLabels) return;
+    const labels = Object.keys(permittedLabels);
+    const values: Set<number> = new Set();
+    for (const label of labels) {
+      for (const value of permittedLabels[label]) {
+        values.add(Number(value));
+      }
+    }
+
+    const orderedValues = Array.from(values.values()).sort((a, b) => a - b);
+
+    const labelValues: LabelValuesMap = {};
+    for (let i = 0; i < orderedValues.length; i++) {
+      labelValues[orderedValues[i]] = i;
+    }
+    this._labelValues = labelValues;
+  }
+
+  _changeIsMerged(changeStatus: string) {
+    return changeStatus === 'MERGED';
+  }
+
+  _computeLabelAccessClass(
+    label?: string,
+    permittedLabels?: LabelNameToValueMap
+  ) {
+    if (!permittedLabels || !label) {
+      return '';
+    }
+
+    return hasOwnProperty(permittedLabels, label) &&
+      permittedLabels[label].length
+      ? 'access'
+      : 'no-access';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-label-scores': 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
deleted file mode 100644
index 1e3e7ec..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
new file mode 100644
index 0000000..7b1fb7f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
@@ -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';
+
+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: none;
+    }
+  </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
deleted file mode 100644
index 7ee86d6..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
+++ /dev/null
@@ -1,196 +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">
-<meta charset="utf-8">
-<title>gr-label-scores</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-label-scores></gr-label-scores>
-  </template>
-</test-fixture>
-
-<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,
-          }],
-        },
-        '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.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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'},
-    ]);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
new file mode 100644
index 0000000..ae639e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -0,0 +1,192 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-label-scores.js';
+
+const basicFixture = fixtureFromElement('gr-label-scores');
+
+suite('gr-label-scores tests', () => {
+  let element;
+
+  setup(done => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = basicFixture.instantiate();
+    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,
+          }],
+        },
+        '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.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    flush(done);
+  });
+
+  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('getLabelValues includeDefaults', async () => {
+    element.change = {
+      _number: '123',
+      labels: {
+        'Code-Review': {
+          values: {'0': 'meh', '+1': 'good', '-1': 'bad'},
+          default_value: 0,
+        },
+      },
+    };
+    await flush();
+
+    assert.deepEqual(element.getLabelValues(true), {'Code-Review': 0});
+    assert.deepEqual(element.getLabelValues(false), {});
+  });
+
+  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'},
+    ]);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
deleted file mode 100644
index 906ed34..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ /dev/null
@@ -1,392 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
-const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
-
-/**
- * @extends Polymer.Element
- */
-class GrMessage extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-message'; }
-  /**
-   * Fired when this message's reply link is tapped.
-   *
-   * @event reply
-   */
-
-  /**
-   * Fired when the message's timestamp is tapped.
-   *
-   * @event message-anchor-tap
-   */
-
-  /**
-   * Fired when a change message is deleted.
-   *
-   * @event change-message-deleted
-   */
-
-  static get properties() {
-    return {
-      changeNum: Number,
-      /** @type {?} */
-      message: Object,
-      author: {
-        type: Object,
-        computed: '_computeAuthor(message)',
-      },
-      comments: {
-        type: Object,
-      },
-      config: Object,
-      hideAutomated: {
-        type: Boolean,
-        value: false,
-      },
-      hidden: {
-        type: Boolean,
-        computed: '_computeIsHidden(hideAutomated, isAutomated)',
-        reflectToAttribute: true,
-      },
-      isAutomated: {
-        type: Boolean,
-        computed: '_computeIsAutomated(message)',
-      },
-      showOnBehalfOf: {
-        type: Boolean,
-        computed: '_computeShowOnBehalfOf(message)',
-      },
-      showReplyButton: {
-        type: Boolean,
-        computed: '_computeShowReplyButton(message, _loggedIn)',
-      },
-      projectName: {
-        type: String,
-        observer: '_projectNameChanged',
-      },
-
-      /**
-       * A mapping from label names to objects representing the minimum and
-       * maximum possible values for that label.
-       */
-      labelExtremes: Object,
-
-      /**
-       * @type {{ commentlinks: Array }}
-       */
-      _projectConfig: Object,
-      // Computed property needed to trigger Polymer value observing.
-      _expanded: {
-        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,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateExpandedClass(message.expanded)',
-    ];
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('click',
-        e => this._handleClick(e));
-  }
-
-  /** @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`;
-    }
-  }
-
-  _computeMessageContentExpanded(content, tag) {
-    return this._computeMessageContent(content, tag, true);
-  }
-
-  _computeMessageContentCollapsed(content, tag) {
-    return this._computeMessageContent(content, tag, false);
-  }
-
-  _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;
-      }
-      if (line.startsWith('(') && line.endsWith(' comment)')) {
-        return false;
-      }
-      if (line.startsWith('(') && line.endsWith(' comments)')) {
-        return false;
-      }
-      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();
-  }
-
-  _computeAuthor(message) {
-    return message.author || message.updated_by;
-  }
-
-  _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(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.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
new file mode 100644
index 0000000..8b0cf4b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -0,0 +1,504 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '@polymer/iron-icon/iron-icon';
+import '../../shared/gr-account-label/gr-account-label';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-formatted-text/gr-formatted-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../../styles/gr-voting-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-message_html';
+import {SpecialFilePath} from '../../../constants/constants';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  ChangeMessageInfo,
+  ServerInfo,
+  ConfigInfo,
+  RepoName,
+  ReviewInputTag,
+  VotingRangeInfo,
+  NumericChangeId,
+  ChangeMessageId,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {CommentThread} from '../../../utils/comment-util';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
+const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-message': GrMessage;
+  }
+}
+
+export interface MessageAnchorTapDetail {
+  id: ChangeMessageId;
+}
+
+export interface GrMessage {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+interface ChangeMessage extends ChangeMessageInfo {
+  // TODO(TS): maybe should be an enum instead
+  type: string;
+  expanded: boolean;
+  commentThreads: CommentThread[];
+}
+
+export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
+
+interface Score {
+  label?: string;
+  value?: string;
+}
+
+@customElement('gr-message')
+export class GrMessage extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when this message's reply link is tapped.
+   *
+   * @event reply
+   */
+
+  /**
+   * Fired when the message's timestamp is tapped.
+   *
+   * @event message-anchor-tap
+   */
+
+  /**
+   * Fired when a change message is deleted.
+   *
+   * @event change-message-deleted
+   */
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Number})
+  changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  message: ChangeMessage | undefined;
+
+  @computed('message')
+  get author() {
+    return this.message?.author || this.message?.updated_by;
+  }
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  @property({type: Boolean})
+  hideAutomated = false;
+
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+    computed: '_computeIsHidden(hideAutomated, isAutomated)',
+  })
+  hidden = false;
+
+  @computed('message')
+  get isAutomated() {
+    return !!this.message && this._computeIsAutomated(this.message);
+  }
+
+  @computed('message')
+  get showOnBehalfOf() {
+    return !!this.message && this._computeShowOnBehalfOf(this.message);
+  }
+
+  @property({
+    type: Boolean,
+    computed: '_computeShowReplyButton(message, _loggedIn)',
+  })
+  showReplyButton = false;
+
+  @property({type: String})
+  projectName?: string;
+
+  /**
+   * A mapping from label names to objects representing the minimum and
+   * maximum possible values for that label.
+   */
+  @property({type: Object})
+  labelExtremes?: LabelExtreme;
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  @property({type: Boolean})
+  _isDeletingChangeMsg = false;
+
+  @property({type: Boolean, computed: '_computeExpanded(message.expanded)'})
+  _expanded = false;
+
+  @property({
+    type: String,
+    computed: '_computeMessageContentExpanded(message.message, message.tag)',
+  })
+  _messageContentExpanded = '';
+
+  @property({
+    type: String,
+    computed:
+      '_computeMessageContentCollapsed(message.message, message.tag,' +
+      ' message.commentThreads)',
+  })
+  _messageContentCollapsed = '';
+
+  @property({
+    type: String,
+    computed: '_computeCommentCountText(message.commentThreads.length)',
+  })
+  _commentCountText = '';
+
+  created() {
+    super.created();
+    this.addEventListener('click', e => this._handleClick(e));
+  }
+
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(config => {
+      this.config = config;
+    });
+    this.$.restAPI.getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+    this.$.restAPI.getIsAdmin().then(isAdmin => {
+      this._isAdmin = !!isAdmin;
+    });
+  }
+
+  @observe('message.expanded')
+  _updateExpandedClass(expanded: boolean) {
+    if (expanded) {
+      this.classList.add('expanded');
+    } else {
+      this.classList.remove('expanded');
+    }
+  }
+
+  _computeCommentCountText(threadsLength?: number) {
+    if (threadsLength === 0) {
+      return undefined;
+    } else if (threadsLength === 1) {
+      return '1 comment';
+    } else {
+      return `${threadsLength} comments`;
+    }
+  }
+
+  _onThreadListModified() {
+    // TODO(taoalpha): this won't propagate 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,
+      })
+    );
+  }
+
+  _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
+    return this._computeMessageContent(content, tag, true);
+  }
+
+  _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
+    const id = this.message?.id;
+    if (!id) return '';
+    const patchsetThreads = commentThreads.filter(
+      thread => thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
+    );
+    for (const thread of patchsetThreads) {
+      // Find if there was a patchset level comment created through the reply
+      // dialog and use it to determine the summary
+      if (thread.comments[0].change_message_id === id) {
+        return thread.comments[0].message;
+      }
+    }
+    // Find if there is a reply to some patchset comment left
+    for (const thread of patchsetThreads) {
+      for (const comment of thread.comments) {
+        if (comment.change_message_id === id) {
+          return comment.message;
+        }
+      }
+    }
+    return '';
+  }
+
+  _computeMessageContentCollapsed(
+    content?: string,
+    tag?: ReviewInputTag,
+    commentThreads?: CommentThread[]
+  ) {
+    const summary = this._computeMessageContent(content, tag, false);
+    if (summary || !commentThreads) return summary;
+    return this._patchsetCommentSummary(commentThreads);
+  }
+
+  _computeMessageContent(
+    content = '',
+    tag: ReviewInputTag = '' as ReviewInputTag,
+    isExpanded: boolean
+  ) {
+    const isNewPatchSet =
+      tag.endsWith(':newPatchSet') || tag.endsWith(':newWipPatchSet');
+    const lines = content.split('\n');
+    const filteredLines = lines.filter(line => {
+      if (!isExpanded && line.startsWith('>')) {
+        return false;
+      }
+      if (line.startsWith('(') && line.endsWith(' comment)')) {
+        return false;
+      }
+      if (line.startsWith('(') && line.endsWith(' comments)')) {
+        return false;
+      }
+      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();
+  }
+
+  _computeAuthor(message: ChangeMessage) {
+    return message.author || message.updated_by;
+  }
+
+  _computeShowOnBehalfOf(message: ChangeMessage) {
+    const author = this._computeAuthor(message);
+    return !!(
+      author &&
+      message.real_author &&
+      author._account_id !== message.real_author._account_id
+    );
+  }
+
+  _computeShowReplyButton(message?: ChangeMessage, loggedIn?: boolean) {
+    return (
+      message &&
+      !!message.message &&
+      loggedIn &&
+      !this._computeIsAutomated(message)
+    );
+  }
+
+  _computeExpanded(expanded: boolean) {
+    return expanded;
+  }
+
+  _handleClick(e: Event) {
+    if (this.message?.expanded) {
+      return;
+    }
+    e.stopPropagation();
+    this.set('message.expanded', true);
+  }
+
+  _handleAuthorClick(e: Event) {
+    if (!this.message?.expanded) {
+      return;
+    }
+    e.stopPropagation();
+    this.set('message.expanded', false);
+  }
+
+  _computeIsAutomated(message: ChangeMessage) {
+    return !!(
+      message.reviewer ||
+      this._computeIsReviewerUpdate(message) ||
+      (message.tag && message.tag.startsWith('autogenerated'))
+    );
+  }
+
+  _computeIsHidden(hideAutomated: boolean, isAutomated: boolean) {
+    return hideAutomated && isAutomated;
+  }
+
+  _computeIsReviewerUpdate(message: ChangeMessage) {
+    return message.type === 'REVIEWER_UPDATE';
+  }
+
+  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
+    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 && hasOwnProperty(labelExtremes, ms[2])
+      )
+      .map(ms => {
+        const label = ms?.[2];
+        const value = ms?.[1] === '-' ? 'removed' : ms?.[3];
+        return {label, value};
+      });
+  }
+
+  _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
+    // Polymer 2: check for undefined
+    if (score === undefined || labelExtremes === undefined) {
+      return '';
+    }
+    if (!score.value) {
+      return '';
+    }
+    if (score.value === 'removed') {
+      return 'removed';
+    }
+    const classes = [];
+    if (Number(score.value) > 0) {
+      classes.push('positive');
+    } else if (Number(score.value) < 0) {
+      classes.push('negative');
+    }
+    if (score.label) {
+      const extremes = labelExtremes[score.label];
+      if (extremes) {
+        const intScore = Number(score.value);
+        if (intScore === extremes.max) {
+          classes.push('max');
+        } else if (intScore === extremes.min) {
+          classes.push('min');
+        }
+      }
+    }
+    return classes.join(' ');
+  }
+
+  _computeClass(expanded: boolean) {
+    const classes = [];
+    classes.push(expanded ? 'expanded' : 'collapsed');
+    return classes.join(' ');
+  }
+
+  _handleAnchorClick(e: Event) {
+    e.preventDefault();
+    // The element which triggers _handleAnchorClick is rendered only if
+    // message.id defined: the elemenet is wrapped in dom-if if="[[message.id]]"
+    const detail: MessageAnchorTapDetail = {
+      id: this.message!.id,
+    };
+    this.dispatchEvent(
+      new CustomEvent('message-anchor-tap', {
+        bubbles: true,
+        composed: true,
+        detail,
+      })
+    );
+  }
+
+  _handleReplyTap(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('reply', {
+        detail: {message: this.message},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleDeleteMessage(e: Event) {
+    e.preventDefault();
+    if (!this.message || !this.message.id || !this.changeNum) 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,
+          })
+        );
+      });
+  }
+
+  @observe('projectName')
+  _projectNameChanged(name: string) {
+    this.$.restAPI.getProjectConfig(name as RepoName).then(config => {
+      this._projectConfig = config;
+    });
+  }
+
+  _computeExpandToggleIcon(expanded: boolean) {
+    return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+  }
+
+  _toggleExpanded(e: Event) {
+    e.stopPropagation();
+    this.set('message.expanded', !this.message?.expanded);
+  }
+}
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
deleted file mode 100644
index 44c12f6..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
+++ /dev/null
@@ -1,304 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
new file mode 100644
index 0000000..96cc959
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -0,0 +1,310 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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-thread-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 {
+      width: 140px;
+    }
+    .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(--vote-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 {
+        min-width: 0px;
+      }
+      .authorLabel {
+        width: 100px;
+      }
+      .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="[[_expanded]]">
+            <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-thread-list
+              change="[[change]]"
+              hidden$="[[!message.commentThreads.length]]"
+              threads="[[message.commentThreads]]"
+              change-num="[[changeNum]]"
+              logged-in="[[_loggedIn]]"
+              hide-toggle-buttons
+              on-thread-list-modified="_onThreadListModified"
+            >
+            </gr-thread-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._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
deleted file mode 100644
index 78c2229..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ /dev/null
@@ -1,456 +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">
-<meta charset="utf-8">
-<title>gr-message</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-message></gr-message>
-  </template>
-</test-fixture>
-
-<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(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(true); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      flush(done);
-    });
-
-    test('reply event', 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: true,
-      };
-
-      element.addEventListener('reply', e => {
-        assert.deepEqual(e.detail.message, element.message);
-        done();
-      });
-      flushAsynchronousOperations();
-      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', () => {
-      element.message = {
-        tag: 'autogenerated:gerrit:test',
-        updated: '2016-01-12 20:24:49.448000000',
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('reviewer message treated as autogenerated', () => {
-      element.message = {
-        tag: 'autogenerated:gerrit:test',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('batch reviewer message treated as autogenerated', () => {
-      element.message = {
-        type: 'REVIEWER_UPDATE',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('tag that is not autogenerated prefix does not hide', () => {
-      element.message = {
-        tag: 'something',
-        updated: '2016-01-12 20:24:49.448000000',
-        expanded: false,
-      };
-
-      assert.isFalse(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isFalse(element.hidden);
-    });
-
-    test('reply button hidden unless logged in', () => {
-      const message = {
-        message: 'Uploaded patch set 1.',
-        expanded: false,
-      };
-      assert.isFalse(element._computeShowReplyButton(message, false));
-      assert.isTrue(element._computeShowReplyButton(message, true));
-    });
-
-    test('_computeShowOnBehalfOf', () => {
-      const message = {
-        message: '...',
-        expanded: false,
-      };
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.author = {_account_id: 1115495};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.real_author = {_account_id: 1115495};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.real_author._account_id = 123456;
-      assert.isOk(element._computeShowOnBehalfOf(message));
-      message.updated_by = message.author;
-      delete message.author;
-      assert.isOk(element._computeShowOnBehalfOf(message));
-      delete message.updated_by;
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-    });
-
-    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
-      test(`${label} ignored for color voting`, () => {
-        element.message = {
-          author: {},
-          expanded: false,
-          message: `Patch Set 1: ${label}+1`,
-        };
-        assert.isNotOk(
-            dom(element.root).querySelector('.negativeVote'));
-        assert.isNotOk(
-            dom(element.root).querySelector('.positiveVote'));
-      });
-    });
-
-    test('clicking on date link fires event', () => {
-      element.message = {
-        type: 'REVIEWER_UPDATE',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        id: '47c43261_55aa2c41',
-        expanded: false,
-      };
-      flushAsynchronousOperations();
-      const stub = sinon.stub();
-      element.addEventListener('message-anchor-tap', stub);
-      const dateEl = element.shadowRoot
-          .querySelector('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-
-      assert.isTrue(stub.called);
-      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-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        'Verified': {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flushAsynchronousOperations();
-      const scoreChips = dom(element.root).querySelectorAll('.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      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: {},
-        expanded: false,
-        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
-      };
-      element.labelExtremes = {};
-      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-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
new file mode 100644
index 0000000..a705d45
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
@@ -0,0 +1,536 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-message.js';
+
+const basicFixture = fixtureFromElement('gr-message');
+
+suite('gr-message tests', () => {
+  let element;
+
+  suite('when admin and logged in', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(true); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
+      });
+      element = basicFixture.instantiate();
+      flush(done);
+    });
+
+    test('reply event', 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: true,
+      };
+
+      element.addEventListener('reply', e => {
+        assert.deepEqual(e.detail.message, element.message);
+        done();
+      });
+      flush();
+      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: true,
+      };
+
+      flush();
+      assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').hidden);
+    });
+
+    test('delete change message', done => {
+      element.changeNum = 314159;
+      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: true,
+      };
+
+      element.addEventListener('change-message-deleted', e => {
+        assert.deepEqual(e.detail.message, element.message);
+        assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').disabled);
+        done();
+      });
+      flush();
+      MockInteractions.tap(element.shadowRoot.querySelector('.deleteBtn'));
+      assert.isTrue(element.shadowRoot.querySelector('.deleteBtn').disabled);
+    });
+
+    test('autogenerated prefix hiding', () => {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('reviewer message treated as autogenerated', () => {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('batch reviewer message treated as autogenerated', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('tag that is not autogenerated prefix does not hide', () => {
+      element.message = {
+        tag: 'something',
+        updated: '2016-01-12 20:24:49.448000000',
+        expanded: false,
+      };
+
+      assert.isFalse(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isFalse(element.hidden);
+    });
+
+    test('reply button hidden unless logged in', () => {
+      const message = {
+        message: 'Uploaded patch set 1.',
+        expanded: false,
+      };
+      assert.isFalse(element._computeShowReplyButton(message, false));
+      assert.isTrue(element._computeShowReplyButton(message, true));
+    });
+
+    test('_computeShowOnBehalfOf', () => {
+      const message = {
+        message: '...',
+        expanded: false,
+      };
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author._account_id = 123456;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      message.updated_by = message.author;
+      delete message.author;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      delete message.updated_by;
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+    });
+
+    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
+      test(`${label} ignored for color voting`, () => {
+        element.message = {
+          author: {},
+          expanded: false,
+          message: `Patch Set 1: ${label}+1`,
+        };
+        assert.isNotOk(
+            element.root.querySelector('.negativeVote'));
+        assert.isNotOk(
+            element.root.querySelector('.positiveVote'));
+      });
+    });
+
+    test('clicking on date link fires event', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        id: '47c43261_55aa2c41',
+        expanded: false,
+      };
+      flush();
+      const stub = sinon.stub();
+      element.addEventListener('message-anchor-tap', stub);
+      const dateEl = element.shadowRoot
+          .querySelector('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      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, element._computeMessageContentCollapsed(
+            original, tag, []));
+        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);
+        assert.equal(actual, element._computeMessageContentCollapsed(
+            original, tag, []));
+        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);
+        assert.equal(actual, element._computeMessageContentCollapsed(
+            original, tag, []));
+        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-Label3+1 Blub+1',
+      };
+      element.labelExtremes = {
+        'Verified': {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Trybot-Label3': {max: 3, min: 0},
+      };
+      flush();
+      const scoreChips = element.root.querySelectorAll('.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[0].classList.contains('positive'));
+      assert.isTrue(scoreChips[0].classList.contains('max'));
+
+      assert.isTrue(scoreChips[1].classList.contains('negative'));
+      assert.isTrue(scoreChips[1].classList.contains('min'));
+
+      assert.isTrue(scoreChips[2].classList.contains('positive'));
+      assert.isFalse(scoreChips[2].classList.contains('min'));
+    });
+
+    test('Uploaded patch set X', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Uploaded 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-Label3': {max: 3, min: 0},
+      };
+      flush();
+      const scoreChips = element.root.querySelectorAll('.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[0].classList.contains('positive'));
+      assert.isTrue(scoreChips[0].classList.contains('max'));
+
+      assert.isTrue(scoreChips[1].classList.contains('negative'));
+      assert.isTrue(scoreChips[1].classList.contains('min'));
+
+      assert.isTrue(scoreChips[2].classList.contains('positive'));
+      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},
+      };
+      flush();
+      const scoreChips = 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: {},
+        expanded: false,
+        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
+      };
+      element.labelExtremes = {};
+      const scoreChips = 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 = basicFixture.instantiate();
+      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: true,
+      };
+
+      flush();
+      assert.isTrue(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      assert.isTrue(
+          element.shadowRoot.querySelector('.deleteBtn').hidden
+      );
+    });
+  });
+
+  suite('patchset comment summary', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.message = {id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3'};
+    });
+
+    test('single patchset comment posted', () => {
+      const threads = [{
+        comments: [{
+          __path: '/PATCHSET_LEVEL',
+          change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
+          patch_set: 1,
+          id: 'e365b138_bed65caa',
+          updated: '2020-05-15 13:35:56.000000000',
+          message: 'testing the load',
+          unresolved: false,
+          path: '/PATCHSET_LEVEL',
+          collapsed: false,
+        }],
+        patchNum: 1,
+        path: '/PATCHSET_LEVEL',
+        rootId: 'e365b138_bed65caa',
+      }];
+      assert.equal(element._computeMessageContentCollapsed(
+          '', undefined, threads), 'testing the load');
+      assert.equal(element._computeMessageContent('', undefined, false), '');
+    });
+
+    test('single patchset comment with reply', () => {
+      const threads = [{
+        comments: [{
+          __path: '/PATCHSET_LEVEL',
+          patch_set: 1,
+          id: 'e365b138_bed65caa',
+          updated: '2020-05-15 13:35:56.000000000',
+          message: 'testing the load',
+          unresolved: false,
+          path: '/PATCHSET_LEVEL',
+          collapsed: false,
+        }, {
+          __path: '/PATCHSET_LEVEL',
+          change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
+          patch_set: 1,
+          id: 'd6efcc85_4cbbb6f4',
+          in_reply_to: 'e365b138_bed65caa',
+          updated: '2020-05-15 16:55:28.000000000',
+          message: 'n',
+          unresolved: false,
+          path: '/PATCHSET_LEVEL',
+          __draft: true,
+          collapsed: true,
+        }],
+        patchNum: 1,
+        path: '/PATCHSET_LEVEL',
+        rootId: 'e365b138_bed65caa',
+      }];
+      assert.equal(element._computeMessageContentCollapsed(
+          '', undefined, threads), 'n');
+      assert.equal(element._computeMessageContent('', undefined, false), '');
+    });
+  });
+
+  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 = basicFixture.instantiate();
+      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: true,
+      };
+
+      flush();
+      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;
+      flush();
+      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: true,
+      };
+      flush();
+      replyEl = element.shadowRoot.querySelector('.replyActionContainer');
+      assert.isOk(replyEl);
+      assert.isFalse(replyEl.hidden);
+    });
+  });
+});
+
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
deleted file mode 100644
index 2da1432..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
+++ /dev/null
@@ -1,346 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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
deleted file mode 100644
index 394d728..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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
deleted file mode 100644
index 9c22ab5..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
+++ /dev/null
@@ -1,453 +0,0 @@
-<!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.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
deleted file mode 100644
index 3cd23d5..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ /dev/null
@@ -1,473 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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 {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 MAX_INITIAL_SHOWN_MESSAGES = 20;
-const MESSAGES_INCREMENT = 5;
-
-const ReportingEvent = {
-  SHOW_ALL: 'show-all-messages',
-  SHOW_MORE: 'show-more-messages',
-};
-
-/**
- * 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,
-        value() { return []; },
-      },
-      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,
-      },
-      /**
-       * The messages after processing and including merged reviewer updates.
-       */
-      _processedMessages: {
-        type: Array,
-        computed: '_computeItems(messages, reviewerUpdates)',
-        observer: '_processedMessagesChanged',
-      },
-      /**
-       * The subset of _processedMessages that is visible to the user.
-       */
-      _visibleMessages: {
-        type: Array,
-        value() { return []; },
-      },
-
-      _labelExtremes: {
-        type: Object,
-        computed: '_computeLabelExtremes(labels.*)',
-      },
-    };
-  }
-
-  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 (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;
-        }
-      }
-    }
-    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;
-        }
-        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]);
-        }
-      }
-    }
-    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;
-    }
-
-    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);
-  }
-
-  /**
-   * 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) {
-      return this._getHumanMessages(messages).length -
-          this._getHumanMessages(visibleMessages).length;
-    }
-    return messages.length - visibleMessages.length;
-  }
-
-  _computeIncrementText(visibleMessages, messages, hideAutomated) {
-    let delta = this._getDelta(visibleMessages, messages, hideAutomated);
-    delta = Math.min(
-        this._numRemaining(visibleMessages, messages, hideAutomated), delta);
-    return 'Show ' + Math.min(MESSAGES_INCREMENT, delta) + ' more';
-  }
-
-  _getHumanMessages(messages) {
-    return messages.filter(msg => !this._isAutomated(msg));
-  }
-
-  _computeShowHideTextHidden(visibleMessages, messages,
-      hideAutomated) {
-    if ([visibleMessages, messages].some(arg => arg === undefined)) {
-      return 0;
-    }
-
-    if (hideAutomated) {
-      messages = this._getHumanMessages(messages);
-      visibleMessages = this._getHumanMessages(visibleMessages);
-    }
-    return visibleMessages.length >= messages.length;
-  }
-
-  _handleShowAllTap() {
-    this._visibleMessages = this._processedMessages;
-    this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
-  }
-
-  _handleIncrementShownMessages() {
-    const delta = this._getDelta(this._visibleMessages,
-        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);
-  }
-
-  _processedMessagesChanged(messages) {
-    if (messages) {
-      this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
-
-      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);
-    }
-  }
-
-  _computeNumMessagesText(visibleMessages, messages,
-      hideAutomated) {
-    const total =
-        this._numRemaining(visibleMessages, messages, hideAutomated);
-    return total === 1 ? 'Show 1 message' : 'Show all ' + total + ' messages';
-  }
-
-  _computeIncrementHidden(visibleMessages, messages,
-      hideAutomated) {
-    const total =
-        this._numRemaining(visibleMessages, messages, hideAutomated);
-    return total <= this._getDelta(visibleMessages, messages, hideAutomated);
-  }
-
-  /**
-   * Compute a mapping from label name to objects representing the minimum and
-   * 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.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
new file mode 100644
index 0000000..8557c10
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -0,0 +1,495 @@
+/**
+ * @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 '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-message/gr-message';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-messages-list_html';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {parseDate} from '../../../utils/date-util';
+import {MessageTag} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ChangeId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  ChangeViewChangeInfo,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  ReviewerUpdateInfo,
+  VotingRangeInfo,
+} from '../../../types/common';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread, isRobot} from '../../../utils/comment-util';
+import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {FormattedReviewerUpdateInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
+import {getVotingRange} from '../../../utils/label-util';
+
+/**
+ * The content of the enum is also used in the UI for the button text.
+ */
+enum ExpandAllState {
+  EXPAND_ALL = 'Expand All',
+  COLLAPSE_ALL = 'Collapse All',
+}
+
+interface TagsCountReportInfo {
+  [tag: string]: number;
+  all: number;
+}
+
+type CombinedMessage = Omit<
+  FormattedReviewerUpdateInfo | ChangeMessageInfo,
+  'tag'
+> & {
+  _revision_number?: PatchSetNum;
+  _index?: number;
+  expanded?: boolean;
+  isImportant?: boolean;
+  commentThreads?: CommentThread[];
+  tag?: string;
+};
+
+function isChangeMessageInfo(x: CombinedMessage): x is ChangeMessageInfo {
+  return (x as ChangeMessageInfo).id !== undefined;
+}
+
+function getMessageId(x: CombinedMessage): ChangeMessageId | undefined {
+  return isChangeMessageInfo(x) ? x.id : undefined;
+}
+
+/**
+ * Computes message author's comments for this change message. The backend
+ * sets comment.change_message_id for matching, so this computation is fairly
+ * straightforward.
+ */
+function computeThreads(
+  message: CombinedMessage,
+  changeComments: ChangeComments
+): CommentThread[] {
+  if (message._index === undefined) {
+    return [];
+  }
+  const messageId = getMessageId(message);
+  return changeComments.getAllThreadsForChange().filter(thread =>
+    thread.comments
+      .map(comment => {
+        // collapse all by default
+        comment.collapsed = true;
+        return comment;
+      })
+      .some(comment => {
+        const condition = comment.change_message_id === messageId;
+        // Since getAllThreadsForChange() always returns a new copy of
+        // all comments we can modify them here without worrying about
+        // polluting other threads.
+        comment.collapsed = !condition;
+        return condition;
+      })
+  );
+}
+
+/**
+ * If messages have the same tag, then that influences grouping and whether
+ * a message is initally hidden or not, see isImportant(). So we are applying
+ * some "magic" rules here in order to hide exactly the right messages.
+ *
+ * 1. If a message does not have a tag, but is associated with robot comments,
+ * then it gets a tag.
+ *
+ * 2. Use the same tag for some of Gerrit's standard events, if they should be
+ * considered one group, e.g. normal and wip patchset uploads.
+ *
+ * 3. Everything beyond the ~ character is cut off from the tag. That gives
+ * tools control over which messages will be hidden.
+ */
+function computeTag(message: CombinedMessage) {
+  if (!message.tag) {
+    const threads = message.commentThreads || [];
+    const messageId = getMessageId(message);
+    const comments = threads.map(t =>
+      t.comments.find(c => c.change_message_id === messageId)
+    );
+    const hasRobotComments = comments.some(isRobot);
+    return hasRobotComments ? 'autogenerated:has-robot-comments' : undefined;
+  }
+
+  if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
+    return MessageTag.TAG_NEW_PATCHSET;
+  }
+  if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
+    return MessageTag.TAG_SET_ASSIGNEE;
+  }
+  if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
+    return MessageTag.TAG_SET_PRIVATE;
+  }
+  if (message.tag === MessageTag.TAG_SET_WIP) {
+    return MessageTag.TAG_SET_READY;
+  }
+
+  return message.tag.replace(/~.*/, '');
+}
+
+/**
+ * Try to set a revision number that makes sense, if none is set. Just copy
+ * over the revision number of the next older message. This is mostly relevant
+ * for reviewer updates. Other messages should typically have the revision
+ * number already set.
+ */
+function computeRevision(
+  message: CombinedMessage,
+  allMessages: CombinedMessage[]
+): PatchSetNum | undefined {
+  if (message._revision_number && message._revision_number > 0)
+    return message._revision_number;
+  let revision: PatchSetNum = 0 as PatchSetNum;
+  for (const m of allMessages) {
+    if (m.date > message.date) break;
+    if (m._revision_number && m._revision_number > revision)
+      revision = m._revision_number;
+  }
+  return revision > 0 ? revision : undefined;
+}
+
+/**
+ * Unimportant messages are initially hidden.
+ *
+ * Human messages are always important. They have an undefined tag.
+ *
+ * Autogenerated messages are unimportant, if there is a message with the same
+ * tag and a higher revision number.
+ */
+function computeIsImportant(
+  message: CombinedMessage,
+  allMessages: CombinedMessage[]
+) {
+  if (!message.tag) return true;
+
+  const hasSameTag = (m: CombinedMessage) => m.tag === message.tag;
+  const revNumber = message._revision_number || 0;
+  const hasHigherRevisionNumber = (m: CombinedMessage) =>
+    (m._revision_number || 0) > revNumber;
+  return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
+}
+
+export const TEST_ONLY = {
+  computeThreads,
+  computeTag,
+  computeRevision,
+  computeIsImportant,
+};
+
+export interface GrMessagesList {
+  $: {
+    messageRepeat: DomRepeat;
+  };
+}
+
+@customElement('gr-messages-list')
+export class GrMessagesList extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  change?: ChangeViewChangeInfo;
+
+  @property({type: String})
+  changeNum?: ChangeId | NumericChangeId;
+
+  @property({type: Array})
+  messages: ChangeMessageInfo[] = [];
+
+  @property({type: Array})
+  reviewerUpdates: ReviewerUpdateInfo[] = [];
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: String})
+  projectName?: RepoName;
+
+  @property({type: Boolean})
+  showReplyButtons = false;
+
+  @property({type: Object})
+  labels?: LabelNameToInfoMap;
+
+  @property({type: String})
+  _expandAllState = ExpandAllState.EXPAND_ALL;
+
+  @property({type: String, computed: '_computeExpandAllTitle(_expandAllState)'})
+  _expandAllTitle = '';
+
+  @property({type: Boolean, observer: '_observeShowAllActivity'})
+  _showAllActivity = false;
+
+  @property({
+    type: Array,
+    computed:
+      '_computeCombinedMessages(messages, reviewerUpdates, ' +
+      'changeComments)',
+    observer: '_combinedMessagesChanged',
+  })
+  _combinedMessages: CombinedMessage[] = [];
+
+  @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
+  _labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
+
+  private readonly reporting = appContext.reportingService;
+
+  scrollToMessage(messageID: string) {
+    const selector = `[data-message-id="${messageID}"]`;
+    const el = this.shadowRoot!.querySelector(selector) as
+      | GrMessage
+      | undefined;
+
+    if (!el && this._showAllActivity) {
+      console.warn(`Failed to scroll to message: ${messageID}`);
+      return;
+    }
+    if (!el) {
+      this._showAllActivity = true;
+      setTimeout(() => this.scrollToMessage(messageID));
+      return;
+    }
+
+    el.set('message.expanded', true);
+    let top = el.offsetTop;
+    for (
+      let offsetParent = el.offsetParent as HTMLElement | null;
+      offsetParent;
+      offsetParent = offsetParent.offsetParent as HTMLElement | null
+    ) {
+      top += offsetParent.offsetTop;
+    }
+    window.scrollTo(0, top);
+    this._highlightEl(el);
+  }
+
+  _observeShowAllActivity() {
+    // 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: CombinedMessage) {
+    return this._showAllActivity || message.isImportant;
+  }
+
+  /**
+   * Merges change messages and reviewer updates into one array. Also processes
+   * all messages and updates, aligns or massages some of the properties.
+   */
+  _computeCombinedMessages(
+    messages?: ChangeMessageInfo[],
+    reviewerUpdates?: FormattedReviewerUpdateInfo[],
+    changeComments?: ChangeComments
+  ) {
+    if (
+      messages === undefined ||
+      reviewerUpdates === undefined ||
+      changeComments === undefined
+    )
+      return [];
+
+    let mi = 0;
+    let ri = 0;
+    let combinedMessages: CombinedMessage[] = [];
+    let mDate;
+    let rDate;
+    for (let i = 0; i < messages.length; i++) {
+      // TODO(TS): clone message instead and avoid API object mutation
+      (messages[i] as CombinedMessage)._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 || parseDate(messages[mi].date);
+      rDate = rDate || 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;
+      }
+      m.commentThreads = computeThreads(m, changeComments);
+      m._revision_number = computeRevision(m, combinedMessages);
+      m.tag = computeTag(m);
+    });
+    // computeIsImportant() depends on tags and revision numbers already being
+    // updated for all messages, so we have to compute this in its own forEach
+    // loop.
+    combinedMessages.forEach(m => {
+      m.isImportant = computeIsImportant(m, combinedMessages);
+    });
+    return combinedMessages;
+  }
+
+  _updateExpandedStateOfAllMessages(exp: boolean) {
+    if (this._combinedMessages) {
+      for (let i = 0; i < this._combinedMessages.length; i++) {
+        this._combinedMessages[i].expanded = exp;
+        this.notifyPath(`_combinedMessages.${i}.expanded`);
+      }
+    }
+  }
+
+  _computeExpandAllTitle(_expandAllState?: string) {
+    if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
+      return this.createTitle(
+        Shortcut.COLLAPSE_ALL_MESSAGES,
+        ShortcutSection.ACTIONS
+      );
+    }
+    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
+      return this.createTitle(
+        Shortcut.EXPAND_ALL_MESSAGES,
+        ShortcutSection.ACTIONS
+      );
+    }
+    return '';
+  }
+
+  _highlightEl(el: HTMLElement) {
+    const highlightedEls = this.root!.querySelectorAll('.highlighted');
+    for (const highlightedEl of highlightedEls) {
+      highlightedEl.classList.remove('highlighted');
+    }
+    function handleAnimationEnd() {
+      el.removeEventListener('animationend', handleAnimationEnd);
+      el.classList.remove('highlighted');
+    }
+    el.addEventListener('animationend', handleAnimationEnd);
+    el.classList.add('highlighted');
+  }
+
+  handleExpandCollapse(expand: boolean) {
+    this._expandAllState = expand
+      ? ExpandAllState.COLLAPSE_ALL
+      : ExpandAllState.EXPAND_ALL;
+    this._updateExpandedStateOfAllMessages(expand);
+  }
+
+  _handleExpandCollapseTap(e: Event) {
+    e.preventDefault();
+    this.handleExpandCollapse(
+      this._expandAllState === ExpandAllState.EXPAND_ALL
+    );
+  }
+
+  _handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
+    this.scrollToMessage(e.detail.id);
+  }
+
+  _isVisibleShowAllActivityToggle(messages: CombinedMessage[] = []) {
+    return messages.some(m => !m.isImportant);
+  }
+
+  _computeHiddenEntriesCount(messages: CombinedMessage[] = []) {
+    return messages.filter(m => !m.isImportant).length;
+  }
+
+  /**
+   * This method is for reporting stats only.
+   */
+  _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
+    if (combinedMessages) {
+      if (combinedMessages.length === 0) return;
+      const tags = combinedMessages.map(
+        message =>
+          message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
+      );
+      const tagsCounted = tags.reduce(
+        (acc, val) => {
+          acc[val] = (acc[val] || 0) + 1;
+          return acc;
+        },
+        {all: combinedMessages.length} as TagsCountReportInfo
+      );
+      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: PolymerDeepPropertyChange<
+      LabelNameToInfoMap,
+      LabelNameToInfoMap
+    >
+  ) {
+    const extremes: {[lableName: string]: VotingRangeInfo} = {};
+    const labels = labelRecord.base;
+    if (!labels) {
+      return extremes;
+    }
+    for (const key of Object.keys(labels)) {
+      const range = getVotingRange(labels[key]);
+      if (range) {
+        extremes[key] = range;
+      }
+    }
+    return extremes;
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapShowAllActivityToggle(e: Event) {
+    e.preventDefault();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-messages-list': 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
deleted file mode 100644
index e47af55..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
new file mode 100644
index 0000000..d3a72072
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -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';
+
+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;
+    }
+    .hiddenEntries {
+      color: var(--deemphasized-text-color);
+    }
+    gr-message:not(:last-of-type) {
+      border-bottom: 1px solid var(--border-color);
+    }
+    gr-message {
+      background-color: var(--background-color-secondary);
+    }
+  </style>
+  <div class="header">
+    <div id="showAllActivityToggleContainer" class="container">
+      <template
+        is="dom-if"
+        if="[[_isVisibleShowAllActivityToggle(_combinedMessages)]]"
+      >
+        <paper-toggle-button
+          class="showAllActivityToggle"
+          checked="{{_showAllActivity}}"
+          aria-labelledby="showAllEntriesLabel"
+          role="switch"
+          on-tap="_onTapShowAllActivityToggle"
+        ></paper-toggle-button>
+        <div id="showAllEntriesLabel" aria-hidden="true">
+          <span>Show all entries</span>
+          <span class="hiddenEntries" hidden$="[[_showAllActivity]]">
+            ([[_computeHiddenEntriesCount(_combinedMessages)]] hidden)
+          </span>
+        </div>
+        <span class="transparent separator"></span>
+      </template>
+    </div>
+    <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="[[change]]"
+      change-num="[[changeNum]]"
+      message="[[message]]"
+      project-name="[[projectName]]"
+      show-reply-button="[[showReplyButtons]]"
+      on-message-anchor-tap="_handleAnchorClick"
+      label-extremes="[[_labelExtremes]]"
+      data-message-id$="[[message.id]]"
+    ></gr-message>
+  </template>
+`;
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
deleted file mode 100644
index 80896aa..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ /dev/null
@@ -1,612 +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">
-<meta charset="utf-8">
-<title>gr-messages-list</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
-        id="messagesList"
-        change-comments="[[_changeComments]]"></gr-messages-list>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-  </template>
-  </dom-module>
-
-<test-fixture id="basic">
-  <template>
-    <comment-api-mock>
-      <gr-messages-list></gr-messages-list>
-    </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.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 tests', () => {
-  let element;
-  let messages;
-  let sandbox;
-  let commentApiWrapper;
-
-  const getMessages = function() {
-    return 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,
-        },
-      },
-      {
-        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({}); },
-        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('show some old messages', () => {
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-      element.messages = _.times(26, randomMessage);
-      flushAsynchronousOperations();
-
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-      assert.equal(getMessages().length, 20);
-      assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
-          .trim(), 'SHOW 5 MORE');
-      MockInteractions.tap(element.$.incrementMessagesBtn);
-      flushAsynchronousOperations();
-
-      assert.equal(getMessages().length, 25);
-      assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
-          .trim(), 'SHOW 1 MORE');
-      MockInteractions.tap(element.$.incrementMessagesBtn);
-      flushAsynchronousOperations();
-
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-      assert.equal(getMessages().length, 26);
-    });
-
-    test('show all old messages', () => {
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-      element.messages = _.times(26, randomMessage);
-      flushAsynchronousOperations();
-
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-      assert.equal(getMessages().length, 20);
-      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-          'SHOW ALL 6 MESSAGES');
-      MockInteractions.tap(element.$.oldMessagesBtn);
-      flushAsynchronousOperations();
-
-      assert.equal(getMessages().length, 26);
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-    });
-
-    test('message count respects automated', () => {
-      element.messages = _.times(10, randomAutomated)
-          .concat(_.times(11, randomMessage));
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-          'SHOW 1 MESSAGE');
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-      MockInteractions.tap(element.$.automatedMessageToggle);
-      flushAsynchronousOperations();
-
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-    });
-
-    test('message count still respects non-automated on toggle', () => {
-      element.messages = _.times(10, randomMessage)
-          .concat(_.times(11, randomAutomated));
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-          'SHOW 1 MESSAGE');
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-      MockInteractions.tap(element.$.automatedMessageToggle);
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-          'SHOW 1 MESSAGE');
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-    });
-
-    test('show all messages respects expand', () => {
-      element.messages = _.times(10, randomAutomated)
-          .concat(_.times(11, randomMessage));
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages')); // Expand all.
-      flushAsynchronousOperations();
-
-      let messages = getMessages();
-      assert.equal(messages.length, 20);
-      for (const message of messages) {
-        assert.isTrue(message._expanded);
-      }
-
-      MockInteractions.tap(element.$.oldMessagesBtn);
-      flushAsynchronousOperations();
-
-      messages = getMessages();
-      assert.equal(messages.length, 21);
-      for (const message of messages) {
-        assert.isTrue(message._expanded);
-      }
-    });
-
-    test('show all messages respects collapse', () => {
-      element.messages = _.times(10, randomAutomated)
-          .concat(_.times(11, randomMessage));
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages')); // Expand all.
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages')); // Collapse all.
-      flushAsynchronousOperations();
-
-      let messages = getMessages();
-      assert.equal(messages.length, 20);
-      for (const message of messages) {
-        assert.isFalse(message._expanded);
-      }
-
-      MockInteractions.tap(element.$.oldMessagesBtn);
-      flushAsynchronousOperations();
-
-      messages = getMessages();
-      assert.equal(messages.length, 21);
-      for (const message of messages) {
-        assert.isFalse(message._expanded);
-      }
-    });
-
-    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.equal(element._visibleMessages.length, 24);
-      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, message) {
-        return message.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));
-      assert.deepEqual(messageElements[1].comments.file2,
-          comments.file2.filter(isMarvin));
-      assert.deepEqual(messageElements[2].comments, {});
-    });
-
-    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);
-    });
-
-    test('hide increment text if increment >= total remaining', () => {
-      // Test with stubbed return values, as _numRemaining and _getDelta have
-      // their own tests.
-      sandbox.stub(element, '_getDelta').returns(5);
-      const remainingStub = sandbox.stub(element, '_numRemaining').returns(6);
-      assert.isFalse(element._computeIncrementHidden(null, null, null));
-      remainingStub.restore();
-
-      sandbox.stub(element, '_numRemaining').returns(4);
-      assert.isTrue(element._computeIncrementHidden(null, null, null));
-    });
-  });
-
-  suite('gr-messages-list 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('_getDelta', () => {
-      let messages = [randomMessage()];
-      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);
-
-      messages = _.times(4, randomMessage)
-          .concat(_.times(2, randomAutomated))
-          .concat(_.times(3, randomMessage));
-
-      const dummyArr = _.times(2, randomMessage);
-      assert.equal(element._getDelta(dummyArr, messages, false), 5);
-      assert.equal(element._getDelta(dummyArr, messages, true), 7);
-    });
-
-    test('_getHumanMessages', () => {
-      assert.equal(
-          element._getHumanMessages(_.times(5, randomAutomated)).length, 0);
-      assert.equal(
-          element._getHumanMessages(_.times(5, randomMessage)).length, 5);
-
-      let messages = _.shuffle(_.times(5, randomMessage)
-          .concat(_.times(5, randomAutomated)));
-      messages = element._getHumanMessages(messages);
-      assert.equal(messages.length, 5);
-      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');
-
-      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_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
new file mode 100644
index 0000000..c27f9fd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -0,0 +1,543 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import './gr-messages-list.js';
+import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
+import {TEST_ONLY} from './gr-messages-list.js';
+import {MessageTag} from '../../../constants/constants.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+createCommentApiMockWithTemplateElement(
+    'gr-messages-list-comment-mock-api', html`
+     <gr-messages-list
+         id="messagesList"
+         change-comments="[[_changeComments]]"></gr-messages-list>
+     <gr-comment-api id="commentAPI"></gr-comment-api>
+`);
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-messages-list-comment-mock-api>
+  <gr-messages-list></gr-messages-list>
+</gr-messages-list-comment-mock-api>
+`);
+
+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,
+    tag: params.tag,
+  };
+};
+
+function generateRandomMessages(count) {
+  return new Array(count).fill()
+      .map(() => randomMessage());
+}
+
+suite('gr-messages-list tests', () => {
+  let element;
+  let messages;
+
+  let commentApiWrapper;
+
+  const getMessages = function() {
+    return 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 createComment = function() {
+    return {
+      id: '1a2b3c4d',
+      message: 'some random test text',
+      change_message_id: '8a7b6c5d',
+      updated: '2016-01-01 01:02:03.000000000',
+      line: 1,
+      patch_set: 1,
+      author,
+    };
+  };
+
+  const comments = {
+    file1: [
+      {
+        ...createComment(),
+        change_message_id: MESSAGE_ID_0,
+        in_reply_to: '6505d749_f0bec0aa',
+        author: {
+          email: 'some@email.com',
+          _account_id: 123,
+        },
+      },
+      {
+        ...createComment(),
+        id: '2b3c4d5e',
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: 'c5912363_6b820105',
+      },
+      {
+        ...createComment(),
+        id: '2b3c4d5e',
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: '6505d749_f0bec0aa',
+      },
+      {
+        ...createComment(),
+        id: '34ed05d749_10ed44b2',
+        change_message_id: MESSAGE_ID_2,
+      },
+    ],
+    file2: [
+      {
+        ...createComment(),
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: 'c5912363_4b7d450a',
+        id: '450a935e_4f260d25',
+      },
+    ],
+  };
+
+  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({}); },
+      });
+
+      messages = generateRandomMessages(3);
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.messagesList;
+      element.messages = messages;
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      return commentApiWrapper.loadComments();
+    });
+
+    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('showAllActivity does not appear when all msgs are important', () => {
+      assert.isOk(element.shadowRoot
+          .querySelector('#showAllActivityToggleContainer'));
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.showAllActivityToggle'));
+    });
+
+    test('scroll to message', () => {
+      const allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message.set('message.expanded', false);
+      }
+
+      const scrollToStub = sinon.stub(window, 'scrollTo');
+      const highlightStub = sinon.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 = sinon.stub(window, 'scrollTo');
+      const highlightStub = sinon.stub(element, '_highlightEl');
+      element.messages = generateRandomMessages(25);
+      flush();
+      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('associating messages with comments', () => {
+      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;
+      flush();
+      const messageElements = getMessages();
+      assert.equal(messageElements.length, messages.length);
+      assert.deepEqual(messageElements[1].message, messages[1]);
+      assert.deepEqual(messageElements[2].message, messages[2]);
+    });
+
+    test('threads', () => {
+      const messages = [
+        {
+          _index: 5,
+          _revision_number: 4,
+          message: 'Uploaded patch set 4.',
+          date: '2016-09-28 13:36:33.000000000',
+          author,
+          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+        },
+      ];
+      element.messages = messages;
+      flush();
+      const messageElements = getMessages();
+      // threads
+      assert.equal(
+          messageElements[0].message.commentThreads.length,
+          3);
+      // first thread contains 1 comment
+      assert.equal(
+          messageElements[0].message.commentThreads[0].comments.length,
+          1);
+    });
+
+    test('updateTag human message', () => {
+      const m = randomMessage();
+      assert.equal(TEST_ONLY.computeTag(m), undefined);
+    });
+
+    test('updateTag nothing to change', () => {
+      const m = randomMessage();
+      const tag = 'something-normal';
+      m.tag = tag;
+      assert.equal(TEST_ONLY.computeTag(m), tag);
+    });
+
+    test('updateTag TAG_NEW_WIP_PATCHSET', () => {
+      const m = randomMessage();
+      m.tag = MessageTag.TAG_NEW_WIP_PATCHSET;
+      assert.equal(TEST_ONLY.computeTag(m), MessageTag.TAG_NEW_PATCHSET);
+    });
+
+    test('updateTag remove postfix', () => {
+      const m = randomMessage();
+      m.tag = 'something~withpostfix';
+      assert.equal(TEST_ONLY.computeTag(m), 'something');
+    });
+
+    test('updateTag with robot comments', () => {
+      const m = randomMessage();
+      m.commentThreads = [{
+        comments: [{
+          robot_id: 'id314',
+          change_message_id: m.id,
+        }],
+      }];
+      assert.notEqual(TEST_ONLY.computeTag(m), undefined);
+    });
+
+    test('setRevisionNumber nothing to change', () => {
+      const m1 = randomMessage();
+      const m2 = randomMessage();
+      assert.equal(TEST_ONLY.computeRevision(m1, [m1, m2]), 1);
+      assert.equal(TEST_ONLY.computeRevision(m2, [m1, m2]), 1);
+    });
+
+    test('setRevisionNumber reviewer updates', () => {
+      const m1 = randomMessage(
+          {
+            tag: MessageTag.TAG_REVIEWER_UPDATE,
+            date: '2020-01-01 10:00:00.000000000',
+          });
+      m1._revision_number = undefined;
+      const m2 = randomMessage(
+          {
+            date: '2020-01-02 10:00:00.000000000',
+          });
+      m2._revision_number = 1;
+      const m3 = randomMessage(
+          {
+            tag: MessageTag.TAG_REVIEWER_UPDATE,
+            date: '2020-01-03 10:00:00.000000000',
+          });
+      m3._revision_number = undefined;
+      const m4 = randomMessage(
+          {
+            date: '2020-01-04 10:00:00.000000000',
+          });
+      m4._revision_number = 2;
+      const m5 = randomMessage(
+          {
+            tag: MessageTag.TAG_REVIEWER_UPDATE,
+            date: '2020-01-05 10:00:00.000000000',
+          });
+      m5._revision_number = undefined;
+      const allMessages = [m1, m2, m3, m4, m5];
+      assert.equal(TEST_ONLY.computeRevision(m1, allMessages), undefined);
+      assert.equal(TEST_ONLY.computeRevision(m2, allMessages), 1);
+      assert.equal(TEST_ONLY.computeRevision(m3, allMessages), 1);
+      assert.equal(TEST_ONLY.computeRevision(m4, allMessages), 2);
+      assert.equal(TEST_ONLY.computeRevision(m5, allMessages), 2);
+    });
+
+    test('isImportant human message', () => {
+      const m = randomMessage();
+      assert.isTrue(TEST_ONLY.computeIsImportant(m, []));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m, [m]));
+    });
+
+    test('isImportant even with a tag', () => {
+      const m1 = randomMessage();
+      const m2 = randomMessage({tag: 'autogenerated:gerrit1'});
+      const m3 = randomMessage({tag: 'autogenerated:gerrit2'});
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, []));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
+    });
+
+    test('isImportant filters same tag and older revision', () => {
+      const m1 = randomMessage({tag: 'auto', _revision_number: 2});
+      const m2 = randomMessage({tag: 'auto', _revision_number: 1});
+      const m3 = randomMessage({tag: 'auto'});
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m2]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
+    });
+
+    test('isImportant is evaluated after tag update', () => {
+      const m1 = randomMessage(
+          {tag: MessageTag.TAG_NEW_PATCHSET, _revision_number: 1});
+      const m2 = randomMessage(
+          {tag: MessageTag.TAG_NEW_WIP_PATCHSET, _revision_number: 2});
+      element.messages = [m1, m2];
+      flush();
+      assert.isFalse(m1.isImportant);
+      assert.isTrue(m2.isImportant);
+    });
+
+    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;
+      flush();
+      const messageEls = getMessages();
+      assert.equal(messageEls.length, 1);
+      assert.equal(messageEls[0].message.message, messages[0].message);
+    });
+  });
+
+  suite('gr-messages-list automate tests', () => {
+    let element;
+    let messages;
+
+    let commentApiWrapper;
+
+    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({}); },
+      });
+
+      messages = [
+        randomMessage(),
+        randomMessage({tag: 'auto', _revision_number: 2}),
+        randomMessage({tag: 'auto', _revision_number: 3}),
+      ];
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.messagesList;
+      sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.messages = messages;
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      return commentApiWrapper.loadComments();
+    });
+
+    test('hide autogenerated button is not hidden', () => {
+      const toggle = element.root.querySelector('.showAllActivityToggle');
+      assert.isOk(toggle);
+    });
+
+    test('one unimportant message is hidden initially', () => {
+      const displayedMsgs = element.root.querySelectorAll('gr-message');
+      assert.equal(displayedMsgs.length, 2);
+    });
+
+    test('unimportant messages hidden after toggle', () => {
+      element._showAllActivity = true;
+      const toggle = element.root.querySelector('.showAllActivityToggle');
+      assert.isOk(toggle);
+      MockInteractions.tap(toggle);
+      flush();
+      const displayedMsgs = element.root.querySelectorAll('gr-message');
+      assert.equal(displayedMsgs.length, 2);
+    });
+
+    test('unimportant messages shown after toggle', () => {
+      element._showAllActivity = false;
+      const toggle = element.root.querySelector('.showAllActivityToggle');
+      assert.isOk(toggle);
+      MockInteractions.tap(toggle);
+      flush();
+      const displayedMsgs = element.root.querySelectorAll('gr-message');
+      assert.equal(displayedMsgs.length, 3);
+    });
+
+    test('_computeLabelExtremes', () => {
+      const computeSpy = sinon.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},
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index 2183dde..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ /dev/null
@@ -1,444 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrRelatedChangesList extends mixinBehaviors( [
-  PatchSetBehavior,
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  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,
-        notify: true,
-        value: false,
-      },
-      patchNum: String,
-      parentChange: Object,
-      hidden: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      loading: {
-        type: Boolean,
-        notify: true,
-      },
-      mergeable: Boolean,
-      _connectedRevisions: {
-        type: Array,
-        computed: '_computeConnectedRevisions(change, patchNum, ' +
-          '_relatedResponse.changes)',
-      },
-      /** @type {?} */
-      _relatedResponse: {
-        type: Object,
-        value() { return {changes: []}; },
-      },
-      /** @type {?} */
-      _submittedTogether: {
-        type: Object,
-        value() { return {changes: []}; },
-      },
-      _conflicts: {
-        type: Array,
-        value() { return []; },
-      },
-      _cherryPicks: {
-        type: Array,
-        value() { return []; },
-      },
-      _sameTopic: {
-        type: Array,
-        value() { return []; },
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_resultsChanged(_relatedResponse, _submittedTogether, ' +
-        '_conflicts, _cherryPicks, _sameTopic)',
-    ];
-  }
-
-  clear() {
-    this.loading = true;
-    this.hidden = true;
-
-    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();
-      }),
-    ];
-
-    // 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;
-    }));
-
-    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.
-    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 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;
-      }
-    }
-
-    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;
-      }
-    }
-    const commits = relatedChanges.map(c => c.commit);
-    let pos = commits.length - 1;
-
-    while (pos >= 0) {
-      const commit = commits[pos].commit;
-      connected.push(commit);
-      if (commit == changeRevision) {
-        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;
-  }
-
-  _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})`;
-  }
-}
-
-customElements.define(GrRelatedChangesList.is, GrRelatedChangesList);
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
new file mode 100644
index 0000000..f8a4af2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -0,0 +1,465 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-related-changes-list_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {ChangeStatus} from '../../../constants/constants';
+import {patchNumEquals} from '../../../utils/patch-set-util';
+import {changeIsOpen} from '../../../utils/change-util';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ChangeId,
+  ChangeInfo,
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+  RelatedChangeAndCommitInfo,
+  RelatedChangesInfo,
+  RepoName,
+  SubmittedTogetherInfo,
+} from '../../../types/common';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export interface GrRelatedChangesList {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+function getEmptySubmitTogetherInfo(): SubmittedTogetherInfo {
+  return {changes: [], non_visible_changes: 0};
+}
+
+function isChangeInfo(
+  x: ChangeInfo | RelatedChangeAndCommitInfo
+): x is ChangeInfo {
+  return (x as ChangeInfo)._number !== undefined;
+}
+
+@customElement('gr-related-changes-list')
+export class GrRelatedChangesList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * 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
+   */
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Boolean, notify: true})
+  hasParent = false;
+
+  @property({type: String})
+  patchNum?: PatchSetNum;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  hidden = false;
+
+  @property({type: Boolean, notify: true})
+  loading?: boolean;
+
+  @property({type: Boolean})
+  mergeable?: boolean;
+
+  @property({
+    type: Array,
+    computed:
+      '_computeConnectedRevisions(change, patchNum, ' +
+      '_relatedResponse.changes)',
+  })
+  _connectedRevisions?: CommitId[];
+
+  @property({type: Object})
+  _relatedResponse: RelatedChangesInfo = {changes: []};
+
+  @property({type: Object})
+  _submittedTogether?: SubmittedTogetherInfo = getEmptySubmitTogetherInfo();
+
+  @property({type: Array})
+  _conflicts: ChangeInfo[] = [];
+
+  @property({type: Array})
+  _cherryPicks: ChangeInfo[] = [];
+
+  @property({type: Array})
+  _sameTopic?: ChangeInfo[] = [];
+
+  clear() {
+    this.loading = true;
+    this.hidden = true;
+
+    this._relatedResponse = {changes: []};
+    this._submittedTogether = getEmptySubmitTogetherInfo();
+    this._conflicts = [];
+    this._cherryPicks = [];
+    this._sameTopic = [];
+  }
+
+  reload() {
+    if (!this.change || !this.patchNum) {
+      return Promise.resolve();
+    }
+    const change = this.change;
+    this.loading = true;
+    const promises: Array<Promise<void>> = [
+      this.$.restAPI
+        .getRelatedChanges(change._number, this.patchNum)
+        .then(response => {
+          if (!response) {
+            throw new Error('getRelatedChanges returned undefined response');
+          }
+          this._relatedResponse = response;
+          this._fireReloadEvent();
+          this.hasParent = this._calculateHasParent(
+            change.change_id,
+            response.changes
+          );
+        }),
+      this.$.restAPI
+        .getChangesSubmittedTogether(change._number)
+        .then(response => {
+          this._submittedTogether = response;
+          this._fireReloadEvent();
+        }),
+      this.$.restAPI
+        .getChangeCherryPicks(change.project, change.change_id, change._number)
+        .then(response => {
+          this._cherryPicks = response || [];
+          this._fireReloadEvent();
+        }),
+    ];
+
+    // Get conflicts if change is open and is mergeable.
+    if (changeIsOpen(change) && this.mergeable) {
+      promises.push(
+        this.$.restAPI.getChangeConflicts(change._number).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 (change.topic) {
+          if (!config) {
+            throw new Error('_getServerConfig returned undefined ');
+          }
+          if (!config.change.submit_whole_topic) {
+            return this.$.restAPI
+              .getChangesWithSameTopic(change.topic, change._number)
+              .then(response => {
+                this._sameTopic = response;
+              });
+          }
+        }
+        this._sameTopic = [];
+        return Promise.resolve();
+      })
+    );
+
+    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.
+    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.
+   */
+  _calculateHasParent(
+    currentChangeId: ChangeId,
+    relatedChanges: RelatedChangeAndCommitInfo[]
+  ) {
+    return (
+      relatedChanges.length > 0 &&
+      relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
+    );
+  }
+
+  _getServerConfig() {
+    return this.$.restAPI.getConfig();
+  }
+
+  _computeChangeURL(
+    changeNum: NumericChangeId,
+    project: RepoName,
+    patchNum?: PatchSetNum
+  ) {
+    return GerritNav.getUrlForChangeById(changeNum, project, patchNum);
+  }
+
+  /**
+   * Do the given objects describe the same change? Compares the changes by
+   * their numbers.
+   */
+  _changesEqual(
+    a: ChangeInfo | RelatedChangeAndCommitInfo,
+    b: ChangeInfo | RelatedChangeAndCommitInfo
+  ) {
+    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).
+   */
+  _getChangeNumber(change?: ChangeInfo | RelatedChangeAndCommitInfo) {
+    // Default to 0 if change property is not defined.
+    if (!change) return 0;
+
+    if (isChangeInfo(change)) {
+      return change._number;
+    }
+    return change._change_number;
+  }
+
+  _computeLinkClass(change: ParsedChangeInfo) {
+    const statuses = [];
+    if (change.status === ChangeStatus.ABANDONED) {
+      statuses.push('strikethrough');
+    }
+    if (change.submittable) {
+      statuses.push('submittable');
+    }
+    return statuses.join(' ');
+  }
+
+  _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
+    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 === ChangeStatus.NEW) {
+      classes.push('hidden');
+    }
+    return classes.join(' ');
+  }
+
+  _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
+    switch (change.status) {
+      case ChangeStatus.MERGED:
+        return 'Merged';
+      case 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
+    );
+  }
+
+  @observe(
+    '_relatedResponse',
+    '_submittedTogether',
+    '_conflicts',
+    '_cherryPicks',
+    '_sameTopic'
+  )
+  _resultsChanged(
+    related: RelatedChangesInfo,
+    submittedTogether: SubmittedTogetherInfo | undefined,
+    conflicts: ChangeInfo[],
+    cherryPicks: ChangeInfo[],
+    sameTopic?: ChangeInfo[]
+  ) {
+    if (!submittedTogether || !sameTopic) {
+      return;
+    }
+    const submittedTogetherChangesCount =
+      (submittedTogether.changes || []).length +
+      (submittedTogether.non_visible_changes || 0);
+    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.
+      submittedTogetherChangesCount ? [{}] : [],
+      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;
+      }
+    }
+
+    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 = getPluginEndpoints().getDetails('related-changes-section');
+    this.hidden = !plugins.some(
+      plugin =>
+        !plugin.domHook ||
+        plugin.domHook.getAllAttached().some(instance => !instance.hidden)
+    );
+  }
+
+  _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
+    return (
+      this._connectedRevisions &&
+      !this._connectedRevisions.includes(change.commit.commit)
+    );
+  }
+
+  _computeConnectedRevisions(
+    change?: ParsedChangeInfo,
+    patchNum?: PatchSetNum,
+    relatedChanges?: RelatedChangeAndCommitInfo[]
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      change === undefined ||
+      patchNum === undefined ||
+      relatedChanges === undefined
+    ) {
+      return undefined;
+    }
+
+    const connected: CommitId[] = [];
+    let changeRevision;
+    if (!change) {
+      return [];
+    }
+    for (const rev in change.revisions) {
+      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
+        changeRevision = rev;
+      }
+    }
+    const commits = relatedChanges.map(c => c.commit);
+    let pos = commits.length - 1;
+
+    while (pos >= 0) {
+      const commit: CommitId = commits[pos].commit;
+      connected.push(commit);
+      // TODO(TS): Ensure that both (commit and changeRevision) are string and use === instead
+      // eslint-disable-next-line eqeqeq
+      if (commit == changeRevision) {
+        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;
+  }
+
+  _computeSubmittedTogetherClass(submittedTogether?: SubmittedTogetherInfo) {
+    if (
+      !submittedTogether ||
+      (submittedTogether.changes.length === 0 &&
+        !submittedTogether.non_visible_changes)
+    ) {
+      return 'hidden';
+    }
+    return '';
+  }
+
+  _computeNonVisibleChangesNote(n: number) {
+    const noun = n === 1 ? 'change' : 'changes';
+    return `(+ ${n} non-visible ${noun})`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-related-changes-list': 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
deleted file mode 100644
index 8241165..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
new file mode 100644
index 0000000..d4cd0f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
@@ -0,0 +1,218 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    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;
+    }
+    .arrowToCurrentChange {
+      position: absolute;
+    }
+    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;
+    }
+    .submittableCheck {
+      padding-left: var(--spacing-s);
+      color: var(--positive-green-text-color);
+      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"
+        >
+          <template is="dom-if" if="[[_changesEqual(related, change)]]">
+            <span
+              role="img"
+              class="arrowToCurrentChange"
+              aria-label="Arrow marking current change"
+              >➔</span
+            >
+          </template>
+          <div class="rightIndent changeContainer">
+            <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"
+        >
+          <template is="dom-if" if="[[_changesEqual(related, change)]]">
+            <span
+              role="img"
+              class="arrowToCurrentChange"
+              aria-label="Arrow marking current change"
+              >➔</span
+            >
+          </template>
+          <div class="changeContainer">
+            <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)]]"
+              role="img"
+              aria-label="Submittable"
+              >✓</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
deleted file mode 100644
index 9054aa3..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ /dev/null
@@ -1,682 +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">
-<meta charset="utf-8">
-<title>gr-related-changes-list</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-related-changes-list></gr-related-changes-list>
-  </template>
-</test-fixture>
-
-<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;
-
-    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);
-      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('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 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-related-changes-list/gr-related-changes-list_test.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
new file mode 100644
index 0000000..3983c5a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
@@ -0,0 +1,641 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-related-changes-list.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {getPluginLoader} 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();
+
+const basicFixture = fixtureFromElement('gr-related-changes-list');
+
+suite('gr-related-changes-list tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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('_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 = sinon.stub();
+    element.patchNum = 7;
+    element.change = {
+      change_id: 123,
+      status: 'NEW',
+    };
+    element.mergeable = true;
+    element.addEventListener('new-section-loaded', loadedStub);
+    sinon.stub(element.$.restAPI, 'getRelatedChanges')
+        .returns(Promise.resolve({changes: []}));
+    sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
+        .returns(Promise.resolve());
+    sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
+        .returns(Promise.resolve());
+    sinon.stub(element.$.restAPI, 'getChangeConflicts')
+        .returns(Promise.resolve());
+
+    return element.reload().then(() => {
+      assert.equal(loadedStub.callCount, 4);
+    });
+  });
+
+  suite('getChangeConflicts resolves undefined', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+
+      sinon.stub(element.$.restAPI, 'getRelatedChanges')
+          .returns(Promise.resolve({changes: []}));
+      sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
+          .returns(Promise.resolve());
+      sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
+          .returns(Promise.resolve());
+      sinon.stub(element.$.restAPI, 'getChangeConflicts')
+          .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 = basicFixture.instantiate();
+
+      sinon.stub(element.$.restAPI, 'getRelatedChanges')
+          .returns(Promise.resolve({changes: []}));
+      sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
+          .returns(Promise.resolve());
+      sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
+          .returns(Promise.resolve());
+      conflictsStub = sinon.stub(element.$.restAPI, 'getChangeConflicts')
+          .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 = sinon.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('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 GerritNav', () => {
+    const getUrlStub = sinon.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', () => {
+      flush();
+      assert.include(element.$.submittedTogether.className, 'hidden');
+    });
+
+    test('no non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change]};
+      flush();
+      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};
+      flush();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.note'));
+      assert.strictEqual(
+          element.shadowRoot
+              .querySelector('.note').innerText.trim(),
+          '(+ 1 non-visible change)');
+    });
+
+    test('visible and non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change], non_visible_changes: 2};
+      flush();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.note'));
+      assert.strictEqual(
+          element.shadowRoot
+              .querySelector('.note').innerText.trim(),
+          '(+ 2 non-visible changes)');
+    });
+  });
+});
+
+suite('gr-related-changes-list plugin tests', () => {
+  let element;
+
+  setup(() => {
+    resetPlugins();
+    element = basicFixture.instantiate();
+  });
+
+  teardown(() => {
+    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');
+    getPluginLoader().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');
+    getPluginLoader().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();
+    });
+  });
+});
+
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
deleted file mode 100644
index d3232e9..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ /dev/null
@@ -1,175 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-reply-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-reply-dialog></gr-reply-dialog>
-  </template>
-</test-fixture>
-
-<test-fixture id="plugin-host">
-  <template>
-    <gr-plugin-host></gr-plugin-host>
-  </template>
-</test-fixture>
-
-<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';
-
-_testOnly_initGerritPluginApi();
-
-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',
-          },
-          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',
-          },
-          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();
-
-    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();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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');
-
-    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-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
new file mode 100644
index 0000000..8a4b1f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -0,0 +1,142 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import './gr-reply-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-reply-dialog-it tests', () => {
+  let element;
+  let changeNum;
+  let patchNum;
+
+  const setupElement = element => {
+    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',
+          },
+          all: [{_account_id: 42, value: 0}],
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+  };
+
+  setup(() => {
+    changeNum = 42;
+    patchNum = 1;
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve({_account_id: 42}); },
+    });
+
+    element = basicFixture.instantiate();
+    setupElement(element);
+    // Allow the elements created by dom-repeat to be stamped.
+    flush();
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('_submit blocked when invalid email is supplied to ccs', () => {
+    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
+    // Stub the below function to avoid side effects from the send promise
+    // resolving.
+    sinon.stub(element, '_purgeReviewersPendingRemove');
+
+    element.$.ccs.$.entry.setText('test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isFalse(sendStub.called);
+    flush();
+
+    element.$.ccs.$.entry.setText('test@test.test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('lgtm plugin', done => {
+    resetPlugins();
+    pluginApi.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');
+        }
+      });
+    }, null, 'http://test.com/plugins/lgtm.js');
+    element = basicFixture.instantiate();
+    setupElement(element);
+    getPluginLoader().loadPlugins([]);
+    getPluginLoader().awaitPluginsLoaded()
+        .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();
+          });
+        });
+  });
+});
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
deleted file mode 100644
index 749f0ba..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ /dev/null
@@ -1,951 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 STORAGE_DEBOUNCE_INTERVAL_MS = 400;
-
-const FocusTarget = {
-  ANY: 'any',
-  BODY: 'body',
-  CCS: 'cc',
-  REVIEWERS: 'reviewers',
-};
-
-const ReviewerTypes = {
-  REVIEWER: 'REVIEWER',
-  CC: 'CC',
-};
-
-const LatestPatchState = {
-  LATEST: 'latest',
-  CHECKING: 'checking',
-  NOT_LATEST: 'not-latest',
-};
-
-const ButtonLabels = {
-  START_REVIEW: 'Start review',
-  SEND: 'Send',
-};
-
-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 EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
-
-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 {
-    /**
-     * @type {{ _number: number, removable_reviewers: Array }}
-     */
-      change: Object,
-      patchNum: String,
-      canBeStarted: {
-        type: Boolean,
-        value: false,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      draft: {
-        type: String,
-        value: '',
-        observer: '_draftChanged',
-      },
-      quote: {
-        type: String,
-        value: '',
-      },
-      /** @type {!Function} */
-      filterReviewerSuggestion: {
-        type: Function,
-        value() {
-          return this._filterReviewerSuggestionGenerator(false);
-        },
-      },
-      /** @type {!Function} */
-      filterCCSuggestion: {
-        type: Function,
-        value() {
-          return this._filterReviewerSuggestionGenerator(true);
-        },
-      },
-      permittedLabels: Object,
-      /**
-       * @type {{ commentlinks: Array }}
-       */
-      projectConfig: Object,
-      knownLatestState: String,
-      underReview: {
-        type: Boolean,
-        value: true,
-      },
-
-      _account: Object,
-      _ccs: Array,
-      /** @type {?Object} */
-      _ccPendingConfirmation: {
-        type: Object,
-        observer: '_reviewerPendingConfirmationUpdated',
-      },
-      _messagePlaceholder: {
-        type: String,
-        computed: '_computeMessagePlaceholder(canBeStarted)',
-      },
-      _owner: Object,
-      /** @type {?} */
-      _pendingConfirmationDetails: Object,
-      _includeComments: {
-        type: Boolean,
-        value: true,
-      },
-      _reviewers: Array,
-      /** @type {?Object} */
-      _reviewerPendingConfirmation: {
-        type: Object,
-        observer: '_reviewerPendingConfirmationUpdated',
-      },
-      _previewFormatting: {
-        type: Boolean,
-        value: false,
-        observer: '_handleHeightChanged',
-      },
-      _reviewersPendingRemove: {
-        type: Object,
-        value: {
-          CC: [],
-          REVIEWER: [],
-        },
-      },
-      _sendButtonLabel: {
-        type: String,
-        computed: '_computeSendButtonLabel(canBeStarted)',
-      },
-      _savingComments: Boolean,
-      _reviewersMutated: {
-        type: Boolean,
-        value: false,
-      },
-      _labelsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _saveTooltip: {
-        type: String,
-        value: ButtonTooltips.SAVE,
-        readOnly: true,
-      },
-      _pluginMessage: {
-        type: String,
-        value: '',
-      },
-      _commentEditing: {
-        type: Boolean,
-        value: false,
-      },
-      _sendDisabled: {
-        type: Boolean,
-        computed: '_computeSendButtonDisabled(canBeStarted, ' +
-          'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-          '_includeComments, disabled, _commentEditing)',
-        observer: '_sendDisabledChanged',
-      },
-      draftCommentThreads: {
-        type: Array,
-        observer: '_handleHeightChanged',
-      },
-    };
-  }
-
-  get keyBindings() {
-    return {
-      'esc': '_handleEscKey',
-      'ctrl+enter meta+enter': '_handleEnterKey',
-    };
-  }
-
-  static get observers() {
-    return [
-      '_changeUpdated(change.reviewers.*, change.owner)',
-      '_ccsChanged(_ccs.splices)',
-      '_reviewersChanged(_reviewers.splices)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getAccount().then(account => {
-      this._account = account || {};
-    });
-
-    this.addEventListener('comment-editing-changed', e => {
-      this._commentEditing = e.detail;
-    });
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
-  }
-
-  open(opt_focusTarget) {
-    this.knownLatestState = LatestPatchState.CHECKING;
-    this.fetchChangeUpdates(this.change, this.$.restAPI)
-        .then(result => {
-          this.knownLatestState = result.isLatest ?
-            LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
-        });
-
-    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;
-      });
-    }
-  }
-
-  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) {
-        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 {};
-          }
-          if (!response.ok) {
-            this.dispatchEvent(new CustomEvent('server-error', {
-              detail: {response},
-              composed: true, bubbles: true,
-            }));
-            return {};
-          }
-
-          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;
-        });
-  }
-
-  _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);
-    }
-  }
-
-  _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;
-    }
-
-    // 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);
-            }
-          }
-        }
-        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(draftCommentThreads) {
-    return draftCommentThreads.length === 0;
-  }
-
-  _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;
-        }
-        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;
-          }
-        }
-      }
-    }
-
-    this._ccs = ccs;
-    this._reviewers = reviewers;
-  }
-
-  _accountOrGroupKey(entry) {
-    return entry.id || entry._account_id;
-  }
-
-  /**
-   * Generates a function to filter out reviewer/CC entries. When isCCs is
-   * 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.dispatchEvent(new CustomEvent('cancel', {
-      composed: true, bubbles: false,
-    }));
-    this.$.textarea.closeDropdown();
-    this._purgeReviewersPendingRemove(true);
-    this._rebuildReviewerArrays(this.change.reviewers, this._owner);
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
new file mode 100644
index 0000000..7b34263
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -0,0 +1,1546 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-formatted-text/gr-formatted-text';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-storage/gr-storage';
+import '../../shared/gr-account-list/gr-account-list';
+import '../gr-label-scores/gr-label-scores';
+import '../gr-thread-list/gr-thread-list';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-reply-dialog_html';
+import {
+  GrReviewerSuggestionsProvider,
+  SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {appContext} from '../../../services/app-context';
+import {
+  ChangeStatus,
+  DraftsAction,
+  ReviewerState,
+  SpecialFilePath,
+} from '../../../constants/constants';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fetchChangeUpdates} from '../../../utils/patch-set-util';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {accountKey, removeServiceUsers} from '../../../utils/account-util';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import {TargetElement} from '../../plugins/gr-plugin-types';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {FixIronA11yAnnouncer} from '../../../types/types';
+import {
+  AccountAddition,
+  AccountInfoInput,
+  GrAccountList,
+  GroupInfoInput,
+  GroupObjectInput,
+  RawAccountInput,
+} from '../../shared/gr-account-list/gr-account-list';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {
+  AccountId,
+  AccountInfo,
+  AttentionSetInput,
+  ChangeInfo,
+  CommentInput,
+  EmailAddress,
+  GroupId,
+  GroupInfo,
+  isAccount,
+  isGroup,
+  isReviewerAccountSuggestion,
+  isReviewerGroupSuggestion,
+  LabelNameToValueMap,
+  ParsedJSON,
+  PatchSetNum,
+  ProjectInfo,
+  ReviewerInput,
+  Reviewers,
+  ReviewInput,
+  ReviewResult,
+  ServerInfo,
+  Suggestion,
+} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
+import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
+import {
+  PolymerDeepPropertyChange,
+  PolymerSplice,
+  PolymerSpliceChange,
+} from '@polymer/polymer/interfaces';
+import {
+  areSetsEqual,
+  assertNever,
+  containsAll,
+} from '../../../utils/common-util';
+import {CommentThread} from '../../../utils/comment-util';
+import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage';
+import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
+import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
+import {isUnresolved} from '../../../utils/comment-util';
+
+const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+
+export enum FocusTarget {
+  ANY = 'any',
+  BODY = 'body',
+  CCS = 'cc',
+  REVIEWERS = 'reviewers',
+}
+
+enum ReviewerType {
+  REVIEWER = 'REVIEWER',
+  CC = 'CC',
+}
+
+enum LatestPatchState {
+  LATEST = 'latest',
+  CHECKING = 'checking',
+  NOT_LATEST = 'not-latest',
+}
+
+const ButtonLabels = {
+  START_REVIEW: 'Start review',
+  SEND: 'Send',
+};
+
+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 EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
+
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+
+interface PendingRemovals {
+  CC: (AccountInfoInput | GroupInfoInput)[];
+  REVIEWER: (AccountInfoInput | GroupInfoInput)[];
+}
+const PENDING_REMOVAL_KEYS: (keyof PendingRemovals)[] = [
+  ReviewerType.CC,
+  ReviewerType.REVIEWER,
+];
+
+export interface GrReplyDialog {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: JsApiService & Element;
+    reviewers: GrAccountList;
+    ccs: GrAccountList;
+    cancelButton: GrButton;
+    sendButton: GrButton;
+    labelScores: GrLabelScores;
+    textarea: GrTextarea;
+    reviewerConfirmationOverlay: GrOverlay;
+    storage: GrStorage;
+  };
+}
+
+@customElement('gr-reply-dialog')
+export class GrReplyDialog extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * 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
+   */
+
+  /**
+   * Fired to reload the change page.
+   *
+   * @event reload
+   */
+
+  FocusTarget = FocusTarget;
+
+  reporting = appContext.reportingService;
+
+  flagsService = appContext.flagsService;
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: String})
+  patchNum?: PatchSetNum;
+
+  @property({type: Boolean})
+  canBeStarted = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeHasDrafts(draft, draftCommentThreads.*)',
+  })
+  hasDrafts = false;
+
+  @property({type: String, observer: '_draftChanged'})
+  draft = '';
+
+  @property({type: String})
+  quote = '';
+
+  @property({type: Object})
+  filterReviewerSuggestion: (input: Suggestion) => boolean;
+
+  @property({type: Object})
+  filterCCSuggestion: (input: Suggestion) => boolean;
+
+  @property({type: Object})
+  permittedLabels?: LabelNameToValueMap;
+
+  @property({type: Object})
+  projectConfig?: ProjectInfo;
+
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: String})
+  knownLatestState?: LatestPatchState;
+
+  @property({type: Boolean})
+  underReview = true;
+
+  @property({type: Object})
+  _account?: AccountInfo;
+
+  @property({type: Array})
+  _ccs: (AccountInfo | GroupInfo)[] = [];
+
+  @property({type: Number})
+  _attentionCcsCount = 0;
+
+  @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
+  _ccPendingConfirmation: GroupObjectInput | null = null;
+
+  @property({
+    type: String,
+    computed: '_computeMessagePlaceholder(canBeStarted)',
+  })
+  _messagePlaceholder?: string;
+
+  @property({type: Object})
+  _owner?: AccountInfo;
+
+  @property({type: Object, computed: '_computeUploader(change)'})
+  _uploader?: AccountInfo;
+
+  @property({type: Object})
+  _pendingConfirmationDetails: GroupObjectInput | null = null;
+
+  @property({type: Boolean})
+  _includeComments = true;
+
+  @property({type: Array})
+  _reviewers: (AccountInfo | GroupInfo)[] = [];
+
+  @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
+  _reviewerPendingConfirmation: GroupObjectInput | null = null;
+
+  @property({type: Boolean, observer: '_handleHeightChanged'})
+  _previewFormatting = false;
+
+  @property({type: Object})
+  _reviewersPendingRemove: PendingRemovals = {
+    CC: [],
+    REVIEWER: [],
+  };
+
+  @property({type: String, computed: '_computeSendButtonLabel(canBeStarted)'})
+  _sendButtonLabel?: string;
+
+  @property({type: Boolean})
+  _savingComments = false;
+
+  @property({type: Boolean})
+  _reviewersMutated = false;
+
+  /**
+   * Signifies that the user has changed their vote on a label or (if they have
+   * not yet voted on a label) if a selected vote is different from the default
+   * vote.
+   */
+  @property({type: Boolean})
+  _labelsChanged = false;
+
+  @property({type: String})
+  readonly _saveTooltip: string = ButtonTooltips.SAVE;
+
+  @property({type: String})
+  _pluginMessage = '';
+
+  @property({type: Boolean})
+  _commentEditing = false;
+
+  @property({type: Boolean})
+  _attentionExpanded = false;
+
+  @property({type: Object})
+  _currentAttentionSet: Set<AccountId> = new Set();
+
+  @property({type: Object})
+  _newAttentionSet: Set<AccountId> = new Set();
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeSendButtonDisabled(canBeStarted, ' +
+      'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
+      '_includeComments, disabled, _commentEditing, _attentionExpanded, ' +
+      '_currentAttentionSet, _newAttentionSet)',
+    observer: '_sendDisabledChanged',
+  })
+  _sendDisabled?: boolean;
+
+  @property({type: Array, observer: '_handleHeightChanged'})
+  draftCommentThreads: CommentThread[] | undefined;
+
+  @property({type: Boolean})
+  _isResolvedPatchsetLevelComment = true;
+
+  @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'})
+  _allReviewers: (AccountInfo | GroupInfo)[] = [];
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+      'ctrl+enter meta+enter': '_handleEnterKey',
+    };
+  }
+
+  _isPatchsetCommentsExperimentEnabled = false;
+
+  constructor() {
+    super();
+    this.filterReviewerSuggestion = this._filterReviewerSuggestionGenerator(
+      false
+    );
+    this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true);
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
+    this._getAccount().then(account => {
+      if (account) this._account = account;
+    });
+
+    this.addEventListener('comment-editing-changed', e => {
+      this._commentEditing = (e as CustomEvent).detail;
+    });
+
+    // Plugins on reply-reviewers endpoint can take advantage of these
+    // events to add / remove reviewers
+
+    this.addEventListener('add-reviewer', e => {
+      // Only support account type, see more from:
+      // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
+      this.$.reviewers.addAccountItem({
+        account: (e as CustomEvent).detail.reviewer,
+      });
+    });
+
+    this.addEventListener('remove-reviewer', e => {
+      this.$.reviewers.removeAccount((e as CustomEvent).detail.reviewer);
+    });
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isPatchsetCommentsExperimentEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.PATCHSET_COMMENTS
+    );
+    this.$.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+  }
+
+  open(focusTarget?: FocusTarget) {
+    if (!this.change) throw new Error('missing required change property');
+    this.knownLatestState = LatestPatchState.CHECKING;
+    fetchChangeUpdates(this.change, this.$.restAPI).then(result => {
+      this.knownLatestState = result.isLatest
+        ? LatestPatchState.LATEST
+        : LatestPatchState.NOT_LATEST;
+    });
+
+    this._focusOn(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;
+      });
+    }
+  }
+
+  _computeHasDrafts(
+    draft: string,
+    draftCommentThreads: PolymerDeepPropertyChange<
+      CommentThread[] | undefined,
+      CommentThread[] | undefined
+    >
+  ) {
+    if (draftCommentThreads.base === undefined) return false;
+    return draft.length > 0 || draftCommentThreads.base.length > 0;
+  }
+
+  focus() {
+    this._focusOn(FocusTarget.ANY);
+  }
+
+  getFocusStops() {
+    const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
+    return {
+      start: this.$.reviewers.focusStart,
+      end,
+    };
+  }
+
+  setLabelValue(label: string, value: string) {
+    const selectorEl = this.$.labelScores.shadowRoot?.querySelector(
+      `gr-label-score-row[name="${label}"]`
+    );
+    if (!selectorEl) {
+      return;
+    }
+    (selectorEl as GrLabelScoreRow).setSelectedValue(value);
+  }
+
+  getLabelValue(label: string) {
+    const selectorEl = this.$.labelScores.shadowRoot?.querySelector(
+      `gr-label-score-row[name="${label}"]`
+    );
+    if (!selectorEl) {
+      return null;
+    }
+
+    return (selectorEl as GrLabelScoreRow).selectedValue;
+  }
+
+  _handleEscKey() {
+    this.cancel();
+  }
+
+  _handleEnterKey() {
+    this._submit();
+  }
+
+  @observe('_ccs.splices')
+  _ccsChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+    this._reviewerTypeChanged(splices, ReviewerType.CC);
+  }
+
+  @observe('_reviewers.splices')
+  _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+    this._reviewerTypeChanged(splices, ReviewerType.REVIEWER);
+  }
+
+  _reviewerTypeChanged(
+    splices: PolymerSpliceChange<AccountInfo[]>,
+    reviewerType: ReviewerType
+  ) {
+    if (splices && splices.indexSplices) {
+      this._reviewersMutated = true;
+      this._processReviewerChange(splices.indexSplices, reviewerType);
+      let key: AccountId | EmailAddress | GroupId | undefined;
+      let index;
+      let account;
+      // Remove any accounts that already exist as a CC for reviewer
+      // or vice versa.
+      const isReviewer = ReviewerType.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 id = account.name || account.email || key;
+            const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
+            this.dispatchEvent(
+              new CustomEvent('show-alert', {
+                detail: {message},
+                composed: true,
+                bubbles: true,
+              })
+            );
+          }
+        }
+      }
+    }
+  }
+
+  _processReviewerChange(
+    indexSplices: Array<PolymerSplice<AccountInfo[]>>,
+    type: ReviewerType
+  ) {
+    for (const splice of indexSplices) {
+      for (const account of splice.removed) {
+        if (!this._reviewersPendingRemove[type]) {
+          console.error('Invalid type ' + type + ' for reviewer.');
+          return;
+        }
+        this._reviewersPendingRemove[type].push(account);
+      }
+    }
+  }
+
+  /**
+   * Resets the state of the _reviewersPendingRemove object, and removes
+   * accounts if necessary.
+   *
+   * @param isCancel true if the action is a cancel.
+   * @param keep map of account IDs that must
+   * not be removed, because they have been readded in another state.
+   */
+  _purgeReviewersPendingRemove(
+    isCancel: boolean,
+    keep = new Map<AccountId | EmailAddress, boolean>()
+  ) {
+    let reviewerArr: (AccountInfoInput | GroupInfoInput)[];
+    for (const type of PENDING_REMOVAL_KEYS) {
+      if (!isCancel) {
+        reviewerArr = this._reviewersPendingRemove[type];
+        for (let i = 0; i < reviewerArr.length; i++) {
+          const reviewer = reviewerArr[i];
+          if (!isAccount(reviewer) || !keep.get(accountKey(reviewer))) {
+            this._removeAccount(reviewer, type as ReviewerType);
+          }
+        }
+      }
+      this._reviewersPendingRemove[type] = [];
+    }
+  }
+
+  /**
+   * Removes an account from the change, both on the backend and the client.
+   * Does nothing if the account is a pending addition.
+   */
+  _removeAccount(
+    account: AccountInfoInput | GroupInfoInput,
+    type: ReviewerType
+  ) {
+    if (!this.change) throw new Error('missing required change property');
+    if (account._pendingAdd || !isAccount(account)) {
+      return;
+    }
+
+    return this.$.restAPI
+      .removeChangeReviewer(this.change._number, accountKey(account))
+      .then((response?: Response) => {
+        if (!response?.ok || !this.change) return;
+
+        const reviewers = this.change.reviewers[type] || [];
+        for (let i = 0; i < reviewers.length; i++) {
+          if (reviewers[i]._account_id === account._account_id) {
+            this.splice(`change.reviewers.${type}`, i, 1);
+            break;
+          }
+        }
+      });
+  }
+
+  _mapReviewer(addition: AccountAddition): ReviewerInput {
+    if (addition.account) {
+      return {reviewer: accountKey(addition.account)};
+    }
+    if (addition.group) {
+      const reviewer = decodeURIComponent(addition.group.id) as GroupId;
+      const confirmed = addition.group.confirmed;
+      return {reviewer, confirmed};
+    }
+    throw new Error('Reviewer must be either an account or a group.');
+  }
+
+  send(
+    includeComments: boolean,
+    startReview: boolean
+  ): Promise<Map<AccountId | EmailAddress, boolean>> {
+    this.reporting.time(SEND_REPLY_TIMING_LABEL);
+    const labels = this.$.labelScores.getLabelValues();
+
+    const reviewInput: ReviewInput = {
+      drafts: includeComments
+        ? DraftsAction.PUBLISH_ALL_REVISIONS
+        : DraftsAction.KEEP,
+      labels,
+    };
+
+    if (startReview) {
+      reviewInput.ready = true;
+    }
+
+    if (isAttentionSetEnabled(this.serverConfig)) {
+      const selfName = getDisplayName(this.serverConfig, this._account);
+      const reason = `${selfName} replied on the change`;
+
+      reviewInput.ignore_automatic_attention_set_rules = true;
+      reviewInput.add_to_attention_set = [];
+      for (const user of this._newAttentionSet) {
+        if (!this._currentAttentionSet.has(user)) {
+          reviewInput.add_to_attention_set.push({user, reason});
+        }
+      }
+      reviewInput.remove_from_attention_set = [];
+      for (const user of this._currentAttentionSet) {
+        if (!this._newAttentionSet.has(user)) {
+          reviewInput.remove_from_attention_set.push({user, reason});
+        }
+      }
+      this.reportAttentionSetChanges(
+        this._attentionExpanded,
+        reviewInput.add_to_attention_set,
+        reviewInput.remove_from_attention_set
+      );
+    }
+
+    if (this.draft) {
+      if (this._isPatchsetCommentsExperimentEnabled) {
+        const comment: CommentInput = {
+          message: this.draft,
+          unresolved: !this._isResolvedPatchsetLevelComment,
+        };
+        reviewInput.comments = {
+          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
+        };
+      } else {
+        reviewInput.message = this.draft;
+      }
+    }
+
+    const accountAdditions = new Map<AccountId | EmailAddress, boolean>();
+    reviewInput.reviewers = this.$.reviewers.additions().map(reviewer => {
+      if (reviewer.account) {
+        accountAdditions.set(accountKey(reviewer.account), true);
+      }
+      return this._mapReviewer(reviewer);
+    });
+    const ccsEl = this.$.ccs;
+    if (ccsEl) {
+      for (const addition of ccsEl.additions()) {
+        if (addition.account) {
+          accountAdditions.set(accountKey(addition.account), true);
+        }
+        const reviewer = this._mapReviewer(addition);
+        reviewer.state = ReviewerState.CC;
+        reviewInput.reviewers.push(reviewer);
+      }
+    }
+
+    this.disabled = true;
+
+    const errFn = (r?: Response | null) => this._handle400Error(r);
+    return this._saveReview(reviewInput, errFn)
+      .then(response => {
+        if (!response) {
+          // Null or undefined response indicates that an error handler
+          // took responsibility, so just return.
+          return new Map<AccountId | EmailAddress, boolean>();
+        }
+        if (!response.ok) {
+          this.dispatchEvent(
+            new CustomEvent('server-error', {
+              detail: {response},
+              composed: true,
+              bubbles: true,
+            })
+          );
+          return new Map<AccountId | EmailAddress, boolean>();
+        }
+
+        this.draft = '';
+        this._includeComments = true;
+        this.dispatchEvent(
+          new CustomEvent('send', {
+            composed: true,
+            bubbles: false,
+          })
+        );
+        this.fire('iron-announce', {text: 'Reply sent'}, {bubbles: true});
+        return accountAdditions;
+      })
+      .then(result => {
+        this.disabled = false;
+        return result;
+      })
+      .catch(err => {
+        this.disabled = false;
+        throw err;
+      });
+  }
+
+  _focusOn(section?: FocusTarget) {
+    // 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());
+    } 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());
+    }
+  }
+
+  _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;
+    }
+
+    // Default to BODY.
+    return FocusTarget.BODY;
+  }
+
+  _isOwner(account?: AccountInfo, change?: ChangeInfo) {
+    if (!account || !change || !change.owner) return false;
+    return account._account_id === change.owner._account_id;
+  }
+
+  _handle400Error(response?: Response | null) {
+    if (!response) throw new Error('Reponse is empty.');
+    // 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((parsed: ParsedJSON) => {
+      const result = parsed as ReviewResult;
+      // Only perform custom error handling for 400s and a parseable
+      // ReviewResult response.
+      if (response && response.status === 400 && result && result.reviewers) {
+        const errors: string[] = [];
+        const addReviewers = Object.values(result.reviewers);
+        addReviewers.forEach(r => errors.push(r.error ?? 'no explanation'));
+        response = {
+          ...response,
+          ok: false,
+          text: () => Promise.resolve(errors.join(', ')),
+        };
+      }
+      this.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+  }
+
+  _computeHideDraftList(draftCommentThreads?: CommentThread[]) {
+    return !draftCommentThreads || draftCommentThreads.length === 0;
+  }
+
+  _computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
+    const total = draftCommentThreads ? draftCommentThreads.length : 0;
+    if (total === 0) {
+      return '';
+    }
+    if (total === 1) {
+      return '1 Draft';
+    }
+    return `${total} Drafts`;
+  }
+
+  _computeMessagePlaceholder(canBeStarted: boolean) {
+    return canBeStarted
+      ? 'Add a note for your reviewers...'
+      : 'Say something nice...';
+  }
+
+  @observe('change.reviewers.*', 'change.owner')
+  _changeUpdated(
+    changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
+    owner: AccountInfo
+  ) {
+    if (changeRecord === undefined || owner === undefined) return;
+    this._rebuildReviewerArrays(changeRecord.base, owner);
+  }
+
+  _rebuildReviewerArrays(changeReviewers: Reviewers, owner: AccountInfo) {
+    this._owner = owner;
+
+    const reviewers = [];
+    const ccs = [];
+
+    if (changeReviewers) {
+      for (const key of Object.keys(changeReviewers)) {
+        if (key !== 'REVIEWER' && key !== 'CC') {
+          console.warn('unexpected reviewer state:', key);
+          continue;
+        }
+        if (!changeReviewers[key]) continue;
+        for (const entry of changeReviewers[key]!) {
+          if (entry._account_id === owner._account_id) {
+            continue;
+          }
+          switch (key) {
+            case 'REVIEWER':
+              reviewers.push(entry);
+              break;
+            case 'CC':
+              ccs.push(entry);
+              break;
+          }
+        }
+      }
+    }
+
+    this._ccs = ccs;
+    this._reviewers = reviewers;
+  }
+
+  _handleAttentionModify() {
+    this._attentionExpanded = true;
+  }
+
+  @observe('_attentionExpanded')
+  _onAttentionExpandedChange() {
+    // If the attention-detail section is expanded without dispatching this
+    // event, then the dialog may expand beyond the screen's bottom border.
+    this.dispatchEvent(
+      new CustomEvent('iron-resize', {composed: true, bubbles: true})
+    );
+  }
+
+  _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
+    return isAttentionSetEnabled(config) && !attentionExpanded;
+  }
+
+  _showAttentionDetails(config?: ServerInfo, attentionExpanded?: boolean) {
+    return isAttentionSetEnabled(config) && attentionExpanded;
+  }
+
+  _computeAttentionButtonTitle(sendDisabled?: boolean) {
+    return sendDisabled
+      ? 'Modify the attention set by adding a comment or use the account ' +
+          'hovercard in the change page.'
+      : 'Edit attention set changes';
+  }
+
+  _handleAttentionClick(e: Event) {
+    const id = (e.target as GrAccountChip)?.account?._account_id;
+    if (!id) return;
+
+    const selfId = (this._account && this._account._account_id) || -1;
+    const ownerId =
+      (this.change && this.change.owner && this.change.owner._account_id) || -1;
+    const self = id === selfId ? '_SELF' : '';
+    const role = id === ownerId ? '_OWNER' : '_REVIEWER';
+
+    if (this._newAttentionSet.has(id)) {
+      this._newAttentionSet.delete(id);
+      this.reporting.reportInteraction('attention-set-chip', {
+        action: `REMOVE${self}${role}`,
+      });
+    } else {
+      this._newAttentionSet.add(id);
+      this.reporting.reportInteraction('attention-set-chip', {
+        action: `ADD${self}${role}`,
+      });
+    }
+
+    // Ensure that Polymer picks up the change.
+    this._newAttentionSet = new Set(this._newAttentionSet);
+  }
+
+  _computeHasNewAttention(
+    account?: AccountInfo,
+    newAttention?: Set<AccountId>
+  ) {
+    return (
+      newAttention &&
+      account &&
+      account._account_id &&
+      newAttention.has(account._account_id)
+    );
+  }
+
+  @observe(
+    '_account',
+    '_reviewers.*',
+    '_ccs.*',
+    'change',
+    'draftCommentThreads',
+    '_includeComments',
+    '_labelsChanged',
+    'hasDrafts'
+  )
+  _computeNewAttention(
+    currentUser?: AccountInfo,
+    reviewers?: PolymerDeepPropertyChange<
+      AccountInfoInput[],
+      AccountInfoInput[]
+    >,
+    ccs?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
+    change?: ChangeInfo,
+    draftCommentThreads?: CommentThread[],
+    includeComments?: boolean,
+    _labelsChanged?: boolean,
+    hasDrafts?: boolean
+  ) {
+    if (
+      currentUser === undefined ||
+      currentUser._account_id === undefined ||
+      reviewers === undefined ||
+      ccs === undefined ||
+      change === undefined ||
+      draftCommentThreads === undefined ||
+      includeComments === undefined
+    ) {
+      return;
+    }
+    // The draft comments are only relevant for the attention set as long as the
+    // user actually plans to publish their drafts.
+    draftCommentThreads = includeComments ? draftCommentThreads : [];
+    const hasVote = !!_labelsChanged;
+    const isOwner = this._isOwner(currentUser, change);
+    const isUploader = this._uploader?._account_id === currentUser._account_id;
+    this._attentionCcsCount = removeServiceUsers(ccs.base).length;
+    this._currentAttentionSet = new Set(
+      Object.keys(change.attention_set || {}).map(id => Number(id) as AccountId)
+    );
+    const newAttention = new Set(this._currentAttentionSet);
+    if (change.status === ChangeStatus.NEW) {
+      // Add everyone that the user is replying to in a comment thread.
+      this._computeCommentAccounts(draftCommentThreads).forEach(id =>
+        newAttention.add(id)
+      );
+      // Remove the current user.
+      newAttention.delete(currentUser._account_id);
+      // Add all new reviewers, but not the current reviewer, if they are also
+      // sending a draft or a label vote.
+      const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
+        !(r._account_id === currentUser._account_id && (hasDrafts || hasVote));
+      reviewers.base
+        .filter(r => r._pendingAdd && r._account_id)
+        .filter(notIsReviewerAndHasDraftOrLabel)
+        .forEach(r => newAttention.add(r._account_id!));
+      // Add owner and uploader, if someone else replies.
+      if (hasDrafts || hasVote) {
+        if (this._uploader?._account_id && !isUploader) {
+          newAttention.add(this._uploader._account_id);
+        }
+        if (change.owner?._account_id && !isOwner) {
+          newAttention.add(change.owner._account_id);
+        }
+      }
+    } else {
+      // The only reason for adding someone to the attention set for merged or
+      // abandoned changes is that someone makes a comment thread unresolved.
+      const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved);
+      if (change.owner && hasUnresolvedDraft) {
+        // A change owner must have an _account_id.
+        newAttention.add(change.owner._account_id!);
+      }
+      // Remove the current user.
+      newAttention.delete(currentUser._account_id);
+    }
+    // Finally make sure that everyone in the attention set is still active as
+    // owner, reviewer or cc.
+    const allAccountIds = this._allAccounts()
+      .map(a => a._account_id)
+      .filter(id => !!id);
+    this._newAttentionSet = new Set(
+      [...newAttention].filter(id => allAccountIds.includes(id))
+    );
+    this._attentionExpanded = this._computeShowAttentionTip(
+      currentUser,
+      change.owner,
+      this._currentAttentionSet,
+      this._newAttentionSet
+    );
+  }
+
+  _computeShowAttentionTip(
+    currentUser?: AccountInfo,
+    owner?: AccountInfo,
+    currentAttentionSet?: Set<AccountId>,
+    newAttentionSet?: Set<AccountId>
+  ) {
+    if (!currentUser || !owner || !currentAttentionSet || !newAttentionSet)
+      return false;
+    const isOwner = currentUser._account_id === owner._account_id;
+    const addedIds = [...newAttentionSet].filter(
+      id => !currentAttentionSet.has(id)
+    );
+    return isOwner && addedIds.length > 2;
+  }
+
+  _computeCommentAccounts(threads: CommentThread[]) {
+    const crLabel = this.change?.labels?.[CODE_REVIEW];
+    const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
+    const accountIds = new Set<AccountId>();
+    threads.forEach(thread => {
+      const unresolved = isUnresolved(thread);
+      thread.comments.forEach(comment => {
+        if (comment.author) {
+          // A comment author must have an _account_id.
+          const authorId = comment.author._account_id!;
+          const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId);
+          if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId);
+        }
+      });
+    });
+    return accountIds;
+  }
+
+  _computeShowNoAttentionUpdate(
+    config?: ServerInfo,
+    currentAttentionSet?: Set<AccountId>,
+    newAttentionSet?: Set<AccountId>,
+    sendDisabled?: boolean
+  ) {
+    return (
+      sendDisabled ||
+      this._computeNewAttentionAccounts(
+        config,
+        currentAttentionSet,
+        newAttentionSet
+      ).length === 0
+    );
+  }
+
+  _computeDoNotUpdateMessage(
+    currentAttentionSet?: Set<AccountId>,
+    newAttentionSet?: Set<AccountId>,
+    sendDisabled?: boolean
+  ) {
+    if (!currentAttentionSet || !newAttentionSet) return '';
+    if (sendDisabled || areSetsEqual(currentAttentionSet, newAttentionSet)) {
+      return 'No changes to the attention set.';
+    }
+    if (containsAll(currentAttentionSet, newAttentionSet)) {
+      return 'No additions to the attention set.';
+    }
+    console.error(
+      '_computeDoNotUpdateMessage() should not be called when users were added to the attention set.'
+    );
+    return '';
+  }
+
+  _computeNewAttentionAccounts(
+    _?: ServerInfo,
+    currentAttentionSet?: Set<AccountId>,
+    newAttentionSet?: Set<AccountId>
+  ) {
+    if (currentAttentionSet === undefined || newAttentionSet === undefined) {
+      return [];
+    }
+    return [...newAttentionSet]
+      .filter(id => !currentAttentionSet.has(id))
+      .map(id => this._findAccountById(id))
+      .filter(account => !!account);
+  }
+
+  _findAccountById(accountId: AccountId) {
+    return this._allAccounts().find(r => r._account_id === accountId);
+  }
+
+  _allAccounts() {
+    let allAccounts: (AccountInfoInput | GroupInfoInput)[] = [];
+    if (this.change && this.change.owner) allAccounts.push(this.change.owner);
+    if (this._uploader) allAccounts.push(this._uploader);
+    if (this._reviewers) allAccounts = [...allAccounts, ...this._reviewers];
+    if (this._ccs) allAccounts = [...allAccounts, ...this._ccs];
+    return removeServiceUsers(allAccounts.filter(isAccount));
+  }
+
+  /**
+   * The newAttentionSet param is only used to force re-computation.
+   */
+  _removeServiceUsers(accounts: AccountInfo[], _: Set<AccountId>) {
+    return removeServiceUsers(accounts);
+  }
+
+  _computeUploader(change: ChangeInfo) {
+    if (
+      !change ||
+      !change.current_revision ||
+      !change.revisions ||
+      !change.revisions[change.current_revision]
+    ) {
+      return undefined;
+    }
+    const rev = change.revisions[change.current_revision];
+
+    if (
+      !rev.uploader ||
+      change.owner._account_id === rev.uploader._account_id
+    ) {
+      return undefined;
+    }
+    return rev.uploader;
+  }
+
+  _accountOrGroupKey(entry: AccountInfo | GroupInfo) {
+    if (isAccount(entry)) return accountKey(entry);
+    if (isGroup(entry)) return entry.id;
+    assertNever(entry, 'entry must be account or group');
+  }
+
+  /**
+   * Generates a function to filter out reviewer/CC entries. When isCCs is
+   * truthy, the function filters out entries that already exist in this._ccs.
+   * When falsy, the function filters entries that exist in this._reviewers.
+   */
+  _filterReviewerSuggestionGenerator(
+    isCCs: boolean
+  ): (input: Suggestion) => boolean {
+    return suggestion => {
+      let entry: AccountInfo | GroupInfo;
+      if (isReviewerAccountSuggestion(suggestion)) {
+        entry = suggestion.account;
+        if (entry._account_id === this._owner?._account_id) {
+          return false;
+        }
+      } else if (isReviewerGroupSuggestion(suggestion)) {
+        entry = suggestion.group;
+      } else {
+        console.warn(
+          'received suggestion that was neither account nor group:',
+          suggestion
+        );
+        return false;
+      }
+
+      const key = this._accountOrGroupKey(entry);
+      const finder = (entry: AccountInfo | GroupInfo) =>
+        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: Event) {
+    e.preventDefault();
+    this.cancel();
+  }
+
+  cancel() {
+    if (!this.change) throw new Error('missing required change property');
+    if (!this._owner) throw new Error('missing required _owner property');
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+    this.$.textarea.closeDropdown();
+    this._purgeReviewersPendingRemove(true);
+    this._rebuildReviewerArrays(this.change.reviewers, this._owner);
+  }
+
+  _saveClickHandler(e: Event) {
+    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: Event) {
+    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: ReviewInput, errFn?: ErrorCallback) {
+    if (!this.change) throw new Error('missing required change property');
+    if (!this.patchNum) throw new Error('missing required patchNum property');
+    return this.$.restAPI.saveChangeReview(
+      this.change._number,
+      this.patchNum,
+      review,
+      errFn
+    );
+  }
+
+  _reviewerPendingConfirmationUpdated(reviewer: RawAccountInput | null) {
+    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);
+      return;
+    }
+    if (this._reviewerPendingConfirmation) {
+      this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
+      this._focusOn(FocusTarget.REVIEWERS);
+      return;
+    }
+    console.error('_confirmPendingReviewer called without pending confirm');
+  }
+
+  _cancelPendingReviewer() {
+    this._ccPendingConfirmation = null;
+    this._reviewerPendingConfirmation = null;
+
+    const target = this._ccPendingConfirmation
+      ? FocusTarget.CCS
+      : FocusTarget.REVIEWERS;
+    this._focusOn(target);
+  }
+
+  _getStorageLocation(): StorageLocation {
+    if (!this.change) throw new Error('missing required change property');
+    return {
+      changeNum: this.change._number,
+      patchNum: '@change',
+      path: '@change',
+    };
+  }
+
+  _loadStoredDraft() {
+    const draft = this.$.storage.getDraftComment(this._getStorageLocation());
+    return 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: string, oldDraft?: string) {
+    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() {
+    this.dispatchEvent(
+      new CustomEvent('autogrow', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleLabelsChanged() {
+    this._labelsChanged =
+      Object.keys(this.$.labelScores.getLabelValues(false)).length !== 0;
+  }
+
+  _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
+    return knownLatestState === value;
+  }
+
+  _reload() {
+    this.dispatchEvent(
+      new CustomEvent('reload', {
+        detail: {clearPatchset: true},
+        bubbles: false,
+        composed: true,
+      })
+    );
+    this.cancel();
+  }
+
+  _computeSendButtonLabel(canBeStarted: boolean) {
+    return canBeStarted
+      ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW
+      : ButtonLabels.SEND;
+  }
+
+  _computeSendButtonTooltip(canBeStarted: boolean) {
+    return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
+  }
+
+  _computeSavingLabelClass(savingComments: boolean) {
+    return savingComments ? 'saving' : '';
+  }
+
+  _computeSendButtonDisabled(
+    canBeStarted?: boolean,
+    draftCommentThreads?: CommentThread[],
+    text?: string,
+    reviewersMutated?: boolean,
+    labelsChanged?: boolean,
+    includeComments?: boolean,
+    disabled?: boolean,
+    commentEditing?: boolean
+  ) {
+    if (
+      canBeStarted === undefined ||
+      draftCommentThreads === undefined ||
+      text === undefined ||
+      reviewersMutated === undefined ||
+      labelsChanged === undefined ||
+      includeComments === undefined ||
+      disabled === undefined ||
+      commentEditing === 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?: PatchSetNum, labelsChanged?: boolean) {
+    let str = `Patch ${patchNum} is not latest.`;
+    if (labelsChanged) {
+      str += ' Voting will have no effect.';
+    }
+    return str;
+  }
+
+  setPluginMessage(message: string) {
+    this._pluginMessage = message;
+  }
+
+  _sendDisabledChanged() {
+    this.dispatchEvent(new CustomEvent('send-disabled-changed'));
+  }
+
+  _getReviewerSuggestionsProvider(change: ChangeInfo) {
+    const provider = GrReviewerSuggestionsProvider.create(
+      this.$.restAPI,
+      change._number,
+      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+    );
+    provider.init();
+    return provider;
+  }
+
+  _getCcSuggestionsProvider(change: ChangeInfo) {
+    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,
+      })
+    );
+  }
+
+  reportAttentionSetChanges(
+    modified: boolean,
+    addedSet?: AttentionSetInput[],
+    removedSet?: AttentionSetInput[]
+  ) {
+    const actions = modified ? ['MODIFIED'] : ['NOT_MODIFIED'];
+    const ownerId =
+      (this.change && this.change.owner && this.change.owner._account_id) || -1;
+    const selfId = (this._account && this._account._account_id) || -1;
+    for (const added of addedSet || []) {
+      const addedId = added.user;
+      const self = addedId === selfId ? '_SELF' : '';
+      const role = addedId === ownerId ? '_OWNER' : '_REVIEWER';
+      actions.push('ADD' + self + role);
+    }
+    for (const removed of removedSet || []) {
+      const removedId = removed.user;
+      const self = removedId === selfId ? '_SELF' : '';
+      const role = removedId === ownerId ? '_OWNER' : '_REVIEWER';
+      actions.push('REMOVE' + self + role);
+    }
+    this.reporting.reportInteraction('attention-set-actions', {actions});
+  }
+
+  _computeAllReviewers() {
+    return [...this._reviewers];
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-reply-dialog': 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
deleted file mode 100644
index 54fd47a..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
+++ /dev/null
@@ -1,322 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
new file mode 100644
index 0000000..c56a5c9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -0,0 +1,622 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    .stickyBottom {
+      background-color: var(--dialog-background-color);
+      box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
+      margin-top: var(--spacing-s);
+      bottom: 0;
+      position: sticky;
+      /* @see Issue 8602 */
+      z-index: 1;
+    }
+    .actions {
+      display: flex;
+      justify-content: space-between;
+    }
+    .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[name='reply-text'] {
+      display: flex;
+      width: 100%;
+    }
+    gr-endpoint-decorator[name='reply-text'] {
+      flex-direction: column;
+    }
+    #textarea {
+      flex: 1;
+    }
+    .previewContainer gr-formatted-text {
+      background: var(--table-header-background-color);
+      padding: var(--spacing-l);
+    }
+    #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;
+    }
+    .preview-formatting {
+      margin-left: var(--spacing-m);
+    }
+    .attention-icon {
+      width: 14px;
+      height: 14px;
+      vertical-align: top;
+      position: relative;
+      top: 3px;
+      --iron-icon-height: 24px;
+      --iron-icon-width: 24px;
+    }
+    .attention .edit-attention-button {
+      vertical-align: top;
+      --padding: 0px 4px;
+    }
+    .attention .edit-attention-button iron-icon {
+      color: inherit;
+    }
+    .attention a,
+    .attention-detail a {
+      text-decoration: none;
+    }
+    .attentionSummary {
+      display: flex;
+      justify-content: space-between;
+    }
+    .attentionSummary {
+      /* The account label for selection is misbehaving currently: It consumes
+         26px height instead of 20px, which is the default line-height and thus
+         the max that can be nicely fit into an inline layout flow. We
+         acknowledge that using a fixed 26px value here is a hack and not a
+         great solution. */
+      line-height: 26px;
+    }
+    .attention-detail .peopleList .accountList {
+      display: flex;
+      flex-wrap: wrap;
+    }
+    .attentionSummary gr-account-label,
+    .attention-detail gr-account-label {
+      --account-max-length: 150px;
+      display: inline-block;
+      padding: var(--spacing-xs) var(--spacing-m);
+      user-select: none;
+      --label-border-radius: 8px;
+    }
+    .attentionSummary gr-account-label {
+      margin: 0 var(--spacing-xs);
+      line-height: var(--line-height-normal);
+      vertical-align: top;
+    }
+    .attention-detail gr-account-label {
+      vertical-align: baseline;
+    }
+    .attentionSummary gr-account-label:focus,
+    .attention-detail gr-account-label:focus {
+      outline: none;
+    }
+    .attentionSummary gr-account-label:hover,
+    .attention-detail gr-account-label:hover {
+      box-shadow: var(--elevation-level-1);
+      cursor: pointer;
+    }
+    .attention-detail .attentionDetailsTitle {
+      display: flex;
+      justify-content: space-between;
+    }
+    .attention-detail .selectUsers {
+      color: var(--deemphasized-text-color);
+      margin-bottom: var(--spacing-m);
+    }
+    .attentionTip {
+      padding: var(--spacing-m);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      margin-top: var(--spacing-m);
+      background-color: var(--assignee-highlight-color);
+    }
+    .attentionTip div iron-icon {
+      margin-right: var(--spacing-s);
+    }
+  </style>
+  <div class="container" tabindex="-1">
+    <section class="peopleContainer">
+      <gr-endpoint-decorator name="reply-reviewers">
+        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+        <gr-endpoint-param name="reviewers" value="[[_allReviewers]]">
+        </gr-endpoint-param>
+        <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>
+          <gr-endpoint-slot name="right"></gr-endpoint-slot>
+        </div>
+        <gr-endpoint-slot name="below"></gr-endpoint-slot>
+      </gr-endpoint-decorator>
+      <div class="peopleList">
+        <div class="peopleListLabel">CC</div>
+        <gr-account-list
+          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">
+      <template is="dom-if" if="[[_isPatchsetCommentsExperimentEnabled]]">
+        <label>
+          <input
+            id="resolvedPatchsetLevelCommentCheckbox"
+            type="checkbox"
+            checked="{{_isResolvedPatchsetLevelComment::change}}"
+          />
+          Resolved
+        </label>
+      </template>
+      <label class="preview-formatting">
+        <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>
+    <div class="stickyBottom">
+      <section
+        hidden$="[[!_showAttentionSummary(serverConfig, _attentionExpanded)]]"
+        class="attention"
+      >
+        <div class="attentionSummary">
+          <div>
+            <template
+              is="dom-if"
+              if="[[_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
+            >
+              <span
+                >[[_computeDoNotUpdateMessage(_currentAttentionSet,
+                _newAttentionSet, _sendDisabled)]]</span
+              >
+            </template>
+            <template
+              is="dom-if"
+              if="[[!_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
+            >
+              <span>Bring to attention of</span>
+              <template
+                is="dom-repeat"
+                items="[[_computeNewAttentionAccounts(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+                as="account"
+              >
+                <gr-account-label
+                  account="[[account]]"
+                  force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                  hide-hovercard=""
+                  on-click="_handleAttentionClick"
+                ></gr-account-label>
+              </template>
+            </template>
+            <gr-button
+              class="edit-attention-button"
+              on-click="_handleAttentionModify"
+              disabled="[[_sendDisabled]]"
+              link=""
+              position-below=""
+              data-label="Edit"
+              data-action-type="change"
+              data-action-key="edit"
+              has-tooltip=""
+              title="[[_computeAttentionButtonTitle(_sendDisabled)]]"
+              role="button"
+              tabindex="0"
+            >
+              <iron-icon icon="gr-icons:edit"></iron-icon>
+              Modify
+            </gr-button>
+          </div>
+          <div>
+            <a
+              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:bug"
+                title="report a problem"
+              ></iron-icon>
+            </a>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:help-outline"
+                title="read documentation"
+              ></iron-icon>
+            </a>
+          </div>
+        </div>
+      </section>
+      <section
+        hidden$="[[!_showAttentionDetails(serverConfig, _attentionExpanded)]]"
+        class="attention-detail"
+      >
+        <div class="attentionDetailsTitle">
+          <div>
+            <span>Modify attention to</span>
+          </div>
+          <div></div>
+          <div>
+            <a
+              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:bug"
+                title="report a problem"
+              ></iron-icon>
+            </a>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:help-outline"
+                title="read documentation"
+              ></iron-icon>
+            </a>
+          </div>
+        </div>
+        <div class="selectUsers">
+          <span
+            >Select chips to set who will be in the attention set after sending
+            this reply</span
+          >
+        </div>
+        <div class="peopleList">
+          <div class="peopleListLabel">Owner</div>
+          <div>
+            <gr-account-label
+              account="[[_owner]]"
+              force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
+              selected$="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
+              deselected$="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
+              hide-hovercard=""
+              on-click="_handleAttentionClick"
+            >
+            </gr-account-label>
+          </div>
+        </div>
+        <template is="dom-if" if="[[_uploader]]">
+          <div class="peopleList">
+            <div class="peopleListLabel">Uploader</div>
+            <div>
+              <gr-account-label
+                account="[[_uploader]]"
+                force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+                selected$="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+                deselected$="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+                hide-hovercard=""
+                on-click="_handleAttentionClick"
+              >
+              </gr-account-label>
+            </div>
+          </div>
+        </template>
+        <div class="peopleList">
+          <div class="peopleListLabel">Reviewers</div>
+          <div>
+            <template
+              is="dom-repeat"
+              items="[[_removeServiceUsers(_reviewers, _newAttentionSet)]]"
+              as="account"
+            >
+              <gr-account-label
+                account="[[account]]"
+                force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                hide-hovercard=""
+                on-click="_handleAttentionClick"
+              >
+              </gr-account-label>
+            </template>
+          </div>
+        </div>
+        <template is="dom-if" if="[[_attentionCcsCount]]">
+          <div class="peopleList">
+            <div class="peopleListLabel">CC</div>
+            <div>
+              <template
+                is="dom-repeat"
+                items="[[_removeServiceUsers(_ccs, _newAttentionSet)]]"
+                as="account"
+              >
+                <gr-account-label
+                  account="[[account]]"
+                  force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                  hide-hovercard=""
+                  on-click="_handleAttentionClick"
+                >
+                </gr-account-label>
+              </template>
+            </div>
+          </div>
+        </template>
+        <template
+          is="dom-if"
+          if="[[_computeShowAttentionTip(_account, _owner, _currentAttentionSet, _newAttentionSet)]]"
+        >
+          <div class="attentionTip">
+            <iron-icon
+              class="pointer"
+              icon="gr-icons:lightbulb-outline"
+            ></iron-icon>
+            Be mindful of requiring attention from too many users.
+          </div>
+        </template>
+      </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>
+  </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>
+`;
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
deleted file mode 100644
index 5a61864..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ /dev/null
@@ -1,1305 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-reply-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-reply-dialog></gr-reply-dialog>
-  </template>
-</test-fixture>
-
-<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);
-        },
-      };
-    },
-  };
-}
-
-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,
-        },
-      },
-    };
-    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');
-
-    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);
-            resolve({
-              ok: true,
-              text() {
-                return Promise.resolve(resultStr);
-              },
-            });
-          } catch (err) {
-            reject(err);
-          }
-        }));
-  }
-
-  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(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('keep draft comments with reply', done => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
-    assert.equal(element._includeComments, false);
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        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.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 {
-        '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', () => {
-        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('_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', () => {
-    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();
-    });
-
-    // 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,
-        }));
-
-    // 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;
-    };
-
-    // 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),
-        '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,
-        },
-        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('_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(() => {
-        assert.isTrue(autoGrowHandler.called);
-        done();
-      });
-    });
-  });
-
-  suite('post review API', () => {
-    let startReviewStub;
-
-    setup(() => {
-      startReviewStub = sandbox.stub(
-          element.$.restAPI,
-          'startReview',
-          () => 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);
-      });
-    });
-  });
-
-  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-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
new file mode 100644
index 0000000..b612a57
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -0,0 +1,1565 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
+import './gr-reply-dialog.js';
+import {mockPromise} from '../../../test/test-utils.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+import {appContext} from '../../../services/app-context.js';
+
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+
+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 getDraftCommentStub;
+  let setDraftCommentStub;
+  let eraseDraftCommentStub;
+
+  let lastId = 0;
+  const makeAccount = function() { return {_account_id: lastId++}; };
+  const makeGroup = function() { return {id: lastId++}; };
+
+  setup(() => {
+    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([]); },
+    });
+
+    sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
+
+    element = basicFixture.instantiate();
+    element.change = {
+      _number: changeNum,
+      owner: {
+        _account_id: 999,
+        display_name: 'Kermit',
+      },
+      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',
+      ],
+    };
+
+    getDraftCommentStub = sinon.stub(element.$.storage, 'getDraftComment');
+    setDraftCommentStub = sinon.stub(element.$.storage, 'setDraftComment');
+    eraseDraftCommentStub = sinon.stub(element.$.storage,
+        'eraseDraftComment');
+
+    // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
+    //     .returns(Promise.resolve({isLatest: true}));
+
+    // Allow the elements created by dom-repeat to be stamped.
+    flush();
+  });
+
+  function stubSaveReview(jsonResponseProducer) {
+    return sinon.stub(
+        element,
+        '_saveReview')
+        .callsFake(review => new Promise((resolve, reject) => {
+          try {
+            const result = jsonResponseProducer(review) || {};
+            const resultStr =
+            element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
+            resolve({
+              ok: true,
+              text() {
+                return Promise.resolve(resultStr);
+              },
+            });
+          } catch (err) {
+            reject(err);
+          }
+        }));
+  }
+
+  test('default to publishing 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,
+            },
+            comments: {
+              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+                message: 'I wholeheartedly disapprove',
+                unresolved: false,
+              }],
+            },
+            reviewers: [],
+          });
+          assert.isFalse(element.$.commentList.hidden);
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        flush(() => {
+          MockInteractions.tap(element.shadowRoot
+              .querySelector('.send'));
+        });
+      });
+    });
+  });
+
+  test('modified attention set', done => {
+    element.serverConfig = {
+      change: {enable_attention_set: true},
+    };
+    element._newAttentionSet = new Set([314]);
+    const buttonEl = element.shadowRoot.querySelector('.edit-attention-button');
+    MockInteractions.tap(buttonEl);
+    flush();
+
+    stubSaveReview(review => {
+      assert.isTrue(review.ignore_automatic_attention_set_rules);
+      assert.deepEqual(review.add_to_attention_set, [{
+        user: 314,
+        reason: 'Anonymous replied on the change',
+      }]);
+      assert.deepEqual(review.remove_from_attention_set, []);
+      done();
+    });
+    MockInteractions.tap(element.shadowRoot.querySelector('.send'));
+  });
+
+  function checkComputeAttention(status, userId, reviewerIds, ownerId,
+      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
+      includeComments = true) {
+    const user = {_account_id: userId};
+    const reviewers = {base: reviewerIds.map(id => {
+      return {_account_id: id};
+    })};
+    const draftThreads = [
+      {comments: []},
+    ];
+    if (hasDraft) {
+      draftThreads[0].comments.push({__draft: true, unresolved: true});
+    }
+    replyToIds.forEach(id => draftThreads[0].comments.push({
+      author: {_account_id: id},
+    }));
+    const change = {
+      owner: {_account_id: ownerId},
+      status,
+      attention_set: {},
+    };
+    attSetIds.forEach(id => change.attention_set[id] = {});
+    if (uploaderId) {
+      change.current_revision = 1;
+      change.revisions = [{}, {uploader: {_account_id: uploaderId}}];
+    }
+    element.change = change;
+    element._reviewers = reviewers.base;
+
+    flush();
+    const hasDrafts = draftThreads.length > 0;
+    element._computeNewAttention(
+        user, reviewers, [], change, draftThreads, includeComments, undefined,
+        hasDrafts);
+    assert.sameMembers([...element._newAttentionSet], expectedIds);
+  }
+
+  test('computeNewAttention NEW', () => {
+    checkComputeAttention('NEW', null, [], 999, [], [], [999]);
+    checkComputeAttention('NEW', 1, [], 999, [], [], [999]);
+    checkComputeAttention('NEW', 1, [], 999, [1], [], [999]);
+    checkComputeAttention('NEW', 1, [22], 999, [], [], [999]);
+    checkComputeAttention('NEW', 1, [22], 999, [22], [], [22, 999]);
+    checkComputeAttention('NEW', 1, [22], 999, [], [22], [22, 999]);
+    checkComputeAttention('NEW', 1, [22, 33], 999, [33], [22], [22, 33, 999]);
+    // If the owner replies, then do not add them.
+    checkComputeAttention('NEW', 1, [], 1, [], [], []);
+    checkComputeAttention('NEW', 1, [], 1, [1], [], []);
+    checkComputeAttention('NEW', 1, [22], 1, [], [], []);
+
+    checkComputeAttention('NEW', 1, [22], 1, [], [22], [22]);
+    checkComputeAttention('NEW', 1, [22, 33], 1, [33], [22], [22, 33]);
+    checkComputeAttention('NEW', 1, [22, 33], 1, [], [22], [22]);
+    checkComputeAttention('NEW', 1, [22, 33], 1, [], [22, 33], [22, 33]);
+    checkComputeAttention('NEW', 1, [22, 33], 1, [22, 33], [], [22, 33]);
+    // with uploader
+    checkComputeAttention('NEW', 1, [], 1, [], [2], [2], 2);
+    checkComputeAttention('NEW', 1, [], 1, [2], [], [2], 2);
+    checkComputeAttention('NEW', 1, [], 3, [], [], [2, 3], 2);
+  });
+
+  test('computeNewAttention MERGED', () => {
+    checkComputeAttention('MERGED', null, [], 999, [], [], []);
+    checkComputeAttention('MERGED', 1, [], 999, [], [], []);
+    checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
+    checkComputeAttention(
+        'MERGED', 1, [], 999, [], [], [], undefined, true, false);
+    checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
+    checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
+    checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
+    checkComputeAttention('MERGED', 1, [22], 999, [], [22], []);
+    checkComputeAttention('MERGED', 1, [22, 33], 999, [33], [22], [33]);
+    checkComputeAttention('MERGED', 1, [], 1, [], [], []);
+    checkComputeAttention('MERGED', 1, [], 1, [], [], [], undefined, true);
+    checkComputeAttention('MERGED', 1, [], 1, [1], [], []);
+    checkComputeAttention('MERGED', 1, [], 1, [1], [], [], undefined, true);
+    checkComputeAttention('MERGED', 1, [22], 1, [], [], []);
+    checkComputeAttention('MERGED', 1, [22], 1, [], [22], []);
+    checkComputeAttention('MERGED', 1, [22, 33], 1, [33], [22], [33]);
+    checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22], []);
+    checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22, 33], []);
+    checkComputeAttention('MERGED', 1, [22, 33], 1, [22, 33], [], [22, 33]);
+  });
+
+  test('computeNewAttention when adding reviewers', () => {
+    const user = {_account_id: 1};
+    const reviewers = {base: [
+      {_account_id: 1, _pendingAdd: true},
+      {_account_id: 2, _pendingAdd: true},
+    ]};
+    const change = {
+      owner: {_account_id: 5},
+      status: 'NEW',
+      attention_set: {},
+    };
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flush();
+
+    element._computeNewAttention(user, reviewers, [], change, [], true);
+    assert.sameMembers([...element._newAttentionSet], [1, 2]);
+
+    // If the user votes on the change, then they should not be added to the
+    // attention set, even if they have just added themselves as reviewer.
+    // But voting should also add the owner (5).
+    const labelsChanged = true;
+    element._computeNewAttention(
+        user, reviewers, [], change, [], true, labelsChanged);
+    assert.sameMembers([...element._newAttentionSet], [2, 5]);
+  });
+
+  test('computeNewAttentionAccounts', () => {
+    element._reviewers = [
+      {_account_id: 123, display_name: 'Ernie'},
+      {_account_id: 321, display_name: 'Bert'},
+    ];
+    element._ccs = [
+      {_account_id: 7, display_name: 'Elmo'},
+    ];
+    const compute = (currentAtt, newAtt) =>
+      element._computeNewAttentionAccounts(
+          undefined, new Set(currentAtt), new Set(newAtt))
+          .map(a => a._account_id);
+
+    assert.sameMembers(compute([], []), []);
+    assert.sameMembers(compute([], [999]), [999]);
+    assert.sameMembers(compute([999], []), []);
+    assert.sameMembers(compute([999], [999]), []);
+    assert.sameMembers(compute([123, 321], [999]), [999]);
+    assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]);
+  });
+
+  test('_computeCommentAccounts', () => {
+    element.change = {
+      labels: {
+        'Code-Review': {
+          all: [
+            {_account_id: 1, value: 0},
+            {_account_id: 2, value: 1},
+            {_account_id: 3, value: 2},
+          ],
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didnt submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+        },
+      },
+    };
+    const threads = [
+      {
+        comments: [
+          {author: {_account_id: 1}, unresolved: false},
+          {author: {_account_id: 2}, unresolved: true},
+        ],
+      },
+      {
+        comments: [
+          {author: {_account_id: 3}, unresolved: false},
+          {author: {_account_id: 4}, unresolved: false},
+        ],
+      },
+    ];
+    const actualAccounts = [...element._computeCommentAccounts(threads)];
+    // Account 3 is not included, because the comment is resolved *and* they
+    // have given the highest possible vote on the Code-Review label.
+    assert.sameMembers(actualAccounts, [1, 2, 4]);
+  });
+
+  test('toggle resolved checkbox', done => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    const checkboxEl = element.shadowRoot.querySelector(
+        '#resolvedPatchsetLevelCommentCheckbox');
+    MockInteractions.tap(checkboxEl);
+    flush(() => {
+      flush(() => {
+        element.draft = 'I wholeheartedly disapprove';
+
+        stubSaveReview(review => {
+          assert.deepEqual(review, {
+            drafts: 'PUBLISH_ALL_REVISIONS',
+            labels: {
+              'Code-Review': 0,
+              'Verified': 0,
+            },
+            comments: {
+              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+                message: 'I wholeheartedly disapprove',
+                unresolved: true,
+              }],
+            },
+            reviewers: [],
+          });
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        flush(() => {
+          MockInteractions.tap(element.shadowRoot
+              .querySelector('.send'));
+        });
+      });
+    });
+  });
+
+  test('keep draft comments with reply', done => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
+    assert.equal(element._includeComments, false);
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    flush(() => {
+      flush(() => {
+        element.draft = 'I wholeheartedly disapprove';
+
+        stubSaveReview(review => {
+          assert.deepEqual(review, {
+            drafts: 'KEEP',
+            labels: {
+              'Code-Review': 0,
+              'Verified': 0,
+            },
+            comments: {
+              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+                message: 'I wholeheartedly disapprove',
+                unresolved: false,
+              }],
+            },
+            reviewers: [],
+          });
+          assert.isTrue(element.$.commentList.hidden);
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        flush(() => {
+          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,
+        },
+        comments: {
+          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+            message: 'I wholeheartedly disapprove',
+            unresolved: false,
+          }],
+        },
+        reviewers: [],
+      });
+    });
+
+    sinon.stub(element.$.labelScores, 'getLabelValues').callsFake( () => {
+      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;
+    flush();
+    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,
+      };
+    }
+    flush();
+
+    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', () => {
+    flush();
+    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', async () => {
+    sinon.stub(window, 'fetch').callsFake(() => {
+      const text = '....{"reviewers":{"id1":{"error":"human readable"}}}';
+      return Promise.resolve(cloneableResponse(400, text));
+    });
+
+    let resolver;
+    const promise = new Promise(r => resolver = r);
+    element.addEventListener('server-error', resolver);
+
+    await flush();
+    element.send();
+
+    const event = await promise;
+    assert.equal(event.target, element);
+    const text = await event.detail.response.text();
+    assert.equal(text, 'human readable');
+  });
+
+  test('non-json 400 is treated as a normal server-error', done => {
+    sinon.stub(window, 'fetch').callsFake(() => {
+      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', () => {
+    sinon.spy(element, '_chooseFocusTarget');
+    flush();
+    const textareaStub = sinon.stub(element.$.textarea, 'async');
+    const reviewerEntryStub = sinon.stub(element.$.reviewers.focusStart,
+        'async');
+    const ccStub = sinon.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 = undefined;
+    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.shadowRoot
+          .querySelector('gr-label-scores').shadowRoot
+          .querySelector(
+              'gr-label-score-row[name="Verified"]')
+          .setSelectedValue(-1);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.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 = sinon.stub(element, '_removeAccount');
+    const mock = function() {
+      element._reviewersPendingRemove = {
+        CC: [makeAccount()],
+        REVIEWER: [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 => {
+    sinon.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', () => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flush();
+
+    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);
+    flush();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer1, reviewer2, reviewer3, cc1]);
+    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
+    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
+
+    element.push('_reviewers', cc4, cc3);
+    flush();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
+    assert.deepEqual(element._ccs, [cc2]);
+    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
+  });
+
+  test('update attention section when reviewers and ccs change', () => {
+    element._account = makeAccount();
+    element._reviewers = [makeAccount(), makeAccount()];
+    element._ccs = [makeAccount(), makeAccount()];
+    element.draftCommentThreads = [];
+    const modifyButton =
+        element.shadowRoot.querySelector('.edit-attention-button');
+    MockInteractions.tap(modifyButton);
+    flush();
+
+    // "Modify" button disabled, because "Send" button is disabled.
+    assert.isFalse(element._attentionExpanded);
+    element.draft = 'a test comment';
+    MockInteractions.tap(modifyButton);
+    flush();
+    assert.isTrue(element._attentionExpanded);
+
+    let accountLabels = Array.from(element.shadowRoot.querySelectorAll(
+        '.attention-detail gr-account-label'));
+    assert.equal(accountLabels.length, 5);
+
+    element.push('_reviewers', makeAccount());
+    element.push('_ccs', makeAccount());
+    flush();
+
+    // The 'attention modified' section collapses and resets when reviewers or
+    // ccs change.
+    assert.isFalse(element._attentionExpanded);
+
+    MockInteractions.tap(
+        element.shadowRoot.querySelector('.edit-attention-button'));
+    flush();
+
+    assert.isTrue(element._attentionExpanded);
+    accountLabels = Array.from(element.shadowRoot.querySelectorAll(
+        '.attention-detail gr-account-label'));
+    assert.equal(accountLabels.length, 7);
+
+    element.pop('_reviewers', makeAccount());
+    element.pop('_reviewers', makeAccount());
+    element.pop('_ccs', makeAccount());
+    element.pop('_ccs', makeAccount());
+
+    MockInteractions.tap(
+        element.shadowRoot.querySelector('.edit-attention-button'));
+    flush();
+
+    accountLabels = Array.from(element.shadowRoot.querySelectorAll(
+        '.attention-detail gr-account-label'));
+    assert.equal(accountLabels.length, 3);
+  });
+
+  test('moving from reviewer to cc', () => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flush();
+
+    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);
+    flush();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer2, reviewer3]);
+    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
+    assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
+
+    element.push('_ccs', reviewer3, reviewer2);
+    flush();
+
+    assert.deepEqual(element._reviewers, []);
+    assert.deepEqual(element._ccs,
+        [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
+    assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
+        [reviewer1, reviewer3, reviewer2]);
+  });
+
+  test('migrate reviewers between states', async () => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flush();
+    const reviewers = element.$.reviewers;
+    const ccs = element.$.ccs;
+    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));
+
+    sinon.stub(element, '_removeAccount').callsFake((account, type) => {
+      mutations.push({state: 'REMOVED', account});
+      return Promise.resolve();
+    });
+
+    // Remove and add to other field.
+    reviewers.dispatchEvent(
+        new CustomEvent('remove', {
+          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,
+        }));
+
+    // 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};
+      if (opt_state) {
+        result.state = opt_state;
+      }
+      return result;
+    };
+
+    // Send and purge and verify moves, delete cc3.
+    await element.send()
+        .then(keepReviewers =>
+          element._purgeReviewersPendingRemove(false, keepReviewers));
+    expect(mutations).to.have.lengthOf(5);
+    expect(mutations[0]).to.deep.equal(mapReviewer(cc1));
+    expect(mutations[1]).to.deep.equal(mapReviewer(cc2));
+    expect(mutations[2]).to.deep.equal(mapReviewer(reviewer1, 'CC'));
+    expect(mutations[3]).to.deep.equal(mapReviewer(reviewer2, 'CC'));
+    expect(mutations[4]).to.deep.equal({account: cc3, state: 'REMOVED'});
+  });
+
+  test('emits cancel on esc key', () => {
+    const cancelHandler = sinon.spy();
+    element.addEventListener('cancel', cancelHandler);
+    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+    flush();
+
+    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');
+    flush();
+  });
+
+  test('emit send on ctrl+enter key', done => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => done());
+    MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
+    flush();
+  });
+
+  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: 'username1',
+          error: error1,
+        },
+        username2: {
+          input: 'username2',
+          error: error2,
+        },
+        username3: {
+          input: 'username3',
+          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 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(() => {
+        assert.isTrue(autoGrowHandler.called);
+        done();
+      });
+    });
+  });
+
+  suite('post review API', () => {
+    let startReviewStub;
+
+    setup(() => {
+      startReviewStub = sinon.stub(
+          element.$.restAPI,
+          'startReview')
+          .callsFake(() => 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);
+      });
+    });
+  });
+
+  suite('start review and save buttons', () => {
+    let sendStub;
+
+    setup(() => {
+      sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
+      element.canBeStarted = true;
+      // Flush to make both Start/Save buttons appear in DOM.
+      flush();
+    });
+
+    test('start review sets ready', () => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
+      flush();
+      assert.isTrue(sendStub.calledWith(true, true));
+    });
+
+    test('save review doesn\'t set ready', () => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      flush();
+      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 = sinon.stub();
+
+        element.addEventListener('comment-refresh', refreshHandler);
+        sinon.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', () => {
+        sinon.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 = sinon.stub(element, 'send').returns(Promise.resolve());
+    // Stub the below function to avoid side effects from the send promise
+    // resolving.
+    sinon.stub(element, '_purgeReviewersPendingRemove');
+    element.draftCommentThreads = [];
+    flush();
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isFalse(sendStub.called);
+
+    element.draftCommentThreads = [{comments: [
+      {__draft: true, path: 'test', line: 1, patch_set: 1},
+    ]}];
+    flush();
+
+    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, path: 'test', line: 1, patch_set: 1},
+    ]}];
+    assert.equal(element.getFocusStops().end, element.$.sendButton);
+  });
+
+  test('setPluginMessage', () => {
+    element.setPluginMessage('foo');
+    assert.equal(element.$.pluginMessage.textContent, 'foo');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
deleted file mode 100644
index 94787e6..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
+++ /dev/null
@@ -1,33 +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.
--->
-<dom-module id="my-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/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
deleted file mode 100644
index c933c7c..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ /dev/null
@@ -1,295 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrReviewerList extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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,
-        reflectToAttribute: true,
-      },
-      mutable: {
-        type: Boolean,
-        value: false,
-      },
-      reviewersOnly: {
-        type: Boolean,
-        value: false,
-      },
-      ccsOnly: {
-        type: Boolean,
-        value: false,
-      },
-
-      _displayedReviewers: {
-        type: Array,
-        value() { return []; },
-      },
-      _reviewers: {
-        type: Array,
-        value() { return []; },
-      },
-      _showInput: {
-        type: Boolean,
-        value: false,
-      },
-      _addLabel: {
-        type: String,
-        computed: '_computeAddLabel(ccsOnly)',
-      },
-      _hiddenReviewerCount: {
-        type: Number,
-        computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
-      },
-
-      // Used for testing.
-      _lastAutocompleteRequest: Object,
-      _xhrPromise: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_reviewersChanged(change.reviewers.*, change.owner, serverConfig)',
-    ];
-  }
-
-  /**
-   * 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}) => {
-          return {
-            [label]: scores
-                .map(v => parseInt(v, 10))
-                .reduce((a, b) => Math.max(a, b))};
-        })
-        .reduce((acc, i) => Object.assign(acc, i), {});
-  }
-
-  /**
-   * Returns max permitted score for reviewer.
-   *
-   * @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;
-  }
-
-  _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 {
-        maxScores.push(`${label}`);
-      }
-    }
-    return maxScores.join(', ');
-  }
-
-  _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;
-      }
-      if (this.ccsOnly && key !== 'CC') {
-        continue;
-      }
-      if (key === 'REVIEWER' || key === 'CC') {
-        result = result.concat(reviewers[key]);
-      }
-    }
-    this._reviewers = result
-        .filter(reviewer => reviewer._account_id != owner._account_id);
-
-    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;
-  }
-
-  _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; }
-
-      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;
-          }
-        }
-      }
-    })
-        .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.dispatchEvent(new CustomEvent('show-reply-dialog', {
-      detail: {value},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _handleViewAll(e) {
-    this._displayedReviewers = this._reviewers;
-  }
-
-  _removeReviewer(id) {
-    return this.$.restAPI.removeChangeReviewer(this.change._number, id);
-  }
-
-  _computeAddLabel(ccsOnly) {
-    return ccsOnly ? 'Add CC' : 'Add reviewer';
-  }
-}
-
-customElements.define(GrReviewerList.is, GrReviewerList);
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
new file mode 100644
index 0000000..9c3fa42
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -0,0 +1,328 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-reviewer-list_html';
+import {isServiceUser} from '../../../utils/account-util';
+import {hasAttention} from '../../../utils/attention-set-util';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  ServerInfo,
+  LabelNameToValueMap,
+  AccountInfo,
+  ApprovalInfo,
+  Reviewers,
+  AccountId,
+  DetailedLabelInfo,
+  EmailAddress,
+} from '../../../types/common';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {isRemovableReviewer} from '../../../utils/change-util';
+import {ReviewerState} from '../../../constants/constants';
+
+export interface GrReviewerList {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-reviewer-list')
+export class GrReviewerList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the "Add reviewer..." button is tapped.
+   *
+   * @event show-reply-dialog
+   */
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  @property({type: Boolean})
+  mutable = false;
+
+  @property({type: Boolean})
+  reviewersOnly = false;
+
+  @property({type: Boolean})
+  ccsOnly = false;
+
+  @property({type: Array})
+  _displayedReviewers: AccountInfo[] = [];
+
+  @property({type: Array})
+  _reviewers: AccountInfo[] = [];
+
+  @property({type: Boolean})
+  _showInput = false;
+
+  @property({type: Object})
+  _xhrPromise?: Promise<Response | undefined>;
+
+  @computed('ccsOnly')
+  get _addLabel() {
+    return this.ccsOnly ? 'Add CC' : 'Add reviewer';
+  }
+
+  @computed('_reviewers', '_displayedReviewers')
+  get _hiddenReviewerCount() {
+    // Polymer 2: check for undefined
+    if (
+      this._reviewers === undefined ||
+      this._displayedReviewers === undefined
+    ) {
+      return undefined;
+    }
+    return this._reviewers.length - this._displayedReviewers.length;
+  }
+
+  /**
+   * 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: LabelNameToValueMap | undefined) {
+    if (!labels) return [];
+    return Object.keys(labels).map(label => {
+      return {
+        label,
+        scores: labels[label].map(v => Number(v)),
+      };
+    });
+  }
+
+  /**
+   * Returns hash of labels to max permitted score.
+   *
+   * @returns labels to max permitted scores hash
+   */
+  _getMaxPermittedScores(change: ChangeInfo) {
+    return this._permittedLabelsToNumericScores(change.permitted_labels)
+      .map(({label, scores}) => {
+        return {
+          [label]: scores.reduce((a, b) => Math.max(a, b)),
+        };
+      })
+      .reduce((acc, i) => Object.assign(acc, i), {});
+  }
+
+  /**
+   * Returns max permitted score for reviewer.
+   */
+  _getReviewerPermittedScore(
+    reviewer: AccountInfo,
+    change: ChangeInfo,
+    label: string
+  ) {
+    // 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) {
+      return NaN;
+    }
+    const detailedLabel = change.labels[label] as DetailedLabelInfo;
+    if (!detailedLabel.all) {
+      return NaN;
+    }
+    const detailed = detailedLabel.all
+      .filter(
+        (approval: ApprovalInfo) =>
+          reviewer._account_id === approval._account_id
+      )
+      .pop();
+    if (!detailed) {
+      return NaN;
+    }
+    if (hasOwnProperty(detailed, 'permitted_voting_range')) {
+      if (!detailed.permitted_voting_range) return NaN;
+      return detailed.permitted_voting_range.max;
+    } else if (hasOwnProperty(detailed, 'value')) {
+      // If preset, user can vote on the label.
+      return 0;
+    }
+    return NaN;
+  }
+
+  _computeVoteableText(reviewer: AccountInfo, change: ChangeInfo) {
+    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}`);
+      }
+    }
+    return maxScores.join(', ');
+  }
+
+  @observe('change.reviewers.*', 'change.owner', 'serverConfig')
+  _reviewersChanged(
+    changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
+    owner: AccountInfo,
+    serverConfig: ServerInfo
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      changeRecord === undefined ||
+      owner === undefined ||
+      serverConfig === undefined ||
+      this.change === undefined
+    ) {
+      return;
+    }
+    let result: AccountInfo[] = [];
+    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]!);
+      }
+    }
+    this._reviewers = result
+      .filter(reviewer => reviewer._account_id !== owner._account_id)
+      // Sort order:
+      // 1. Human users in the attention set.
+      // 2. Other human users.
+      // 3. Service users.
+      .sort((r1, r2) => {
+        const a1 = hasAttention(serverConfig, r1, this.change!) ? 1 : 0;
+        const a2 = hasAttention(serverConfig, r2, this.change!) ? 1 : 0;
+        const s1 = isServiceUser(r1) ? -2 : 0;
+        const s2 = isServiceUser(r2) ? -2 : 0;
+        return a2 - a1 + s2 - s1;
+      });
+
+    if (this._reviewers.length > 8) {
+      this._displayedReviewers = this._reviewers.slice(0, 6);
+    } else {
+      this._displayedReviewers = this._reviewers;
+    }
+  }
+
+  _computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
+    return mutable && isRemovableReviewer(this.change, reviewer);
+  }
+
+  _handleRemove(e: Event) {
+    e.preventDefault();
+    const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
+    if (!target.account || !this.change) {
+      return;
+    }
+    const accountID = target.account._account_id || target.account.email;
+    this.disabled = true;
+    if (!accountID) return;
+    this._xhrPromise = this._removeReviewer(accountID)
+      .then((response: Response | undefined) => {
+        this.disabled = false;
+        if (!response || !response.ok) {
+          return response;
+        }
+        if (!this.change || !this.change.reviewers) return;
+        const reviewers = this.change.reviewers;
+        for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+          const reviewerStateByType = reviewers[type] || [];
+          reviewers[type] = reviewerStateByType;
+          for (let i = 0; i < reviewerStateByType.length; i++) {
+            if (
+              reviewerStateByType[i]._account_id === accountID ||
+              reviewerStateByType[i].email === accountID
+            ) {
+              this.splice('change.reviewers.' + type, i, 1);
+              break;
+            }
+          }
+        }
+        return;
+      })
+      .catch((err: Error) => {
+        this.disabled = false;
+        throw err;
+      });
+  }
+
+  _handleAddTap(e: Event) {
+    e.preventDefault();
+    const value = {
+      reviewersOnly: false,
+      ccsOnly: false,
+    };
+    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,
+      })
+    );
+  }
+
+  _handleViewAll() {
+    this._displayedReviewers = this._reviewers;
+  }
+
+  _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
+    if (!this.change) return Promise.resolve(undefined);
+    return this.$.restAPI.removeChangeReviewer(this.change._number, id);
+  }
+}
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
deleted file mode 100644
index 93926cf..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
new file mode 100644
index 0000000..616a7db
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
@@ -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';
+
+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]]"
+          change="[[change]]"
+          on-remove="_handleRemove"
+          highlight-attention
+          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
deleted file mode 100644
index 6949afc..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ /dev/null
@@ -1,323 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-reviewer-list</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-reviewer-list></gr-reviewer-list>
-  </template>
-</test-fixture>
-
-<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');
-    element.serverConfig = {};
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      removeChangeReviewer() {
-        return Promise.resolve({ok: true});
-      },
-    });
-  });
-
-  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('only show remove for removable reviewers', () => {
-    element.mutable = true;
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        REVIEWER: [
-          {
-            _account_id: 2,
-            name: 'Bojack Horseman',
-            email: 'SecretariatRulez96@hotmail.com',
-          },
-          {
-            _account_id: 3,
-            name: 'Pinky Penguin',
-          },
-        ],
-        CC: [
-          {
-            _account_id: 4,
-            name: 'Diane Nguyen',
-            email: 'macarthurfellow2B@juno.com',
-          },
-          {
-            email: 'test@e.mail',
-          },
-        ],
-      },
-      removable_reviewers: [
-        {
-          _account_id: 3,
-          name: 'Pinky Penguin',
-        },
-        {
-          _account_id: 4,
-          name: 'Diane Nguyen',
-          email: 'macarthurfellow2B@juno.com',
-        },
-        {
-          email: 'test@e.mail',
-        },
-      ],
-    };
-    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);
-
-      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, '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-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
new file mode 100644
index 0000000..d29abfc
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -0,0 +1,401 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-reviewer-list.js';
+
+const basicFixture = fixtureFromElement('gr-reviewer-list');
+
+suite('gr-reviewer-list tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.serverConfig = {};
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      removeChangeReviewer() {
+        return Promise.resolve({ok: true});
+      },
+    });
+  });
+
+  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('only show remove for removable reviewers', () => {
+    element.mutable = true;
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        REVIEWER: [
+          {
+            _account_id: 2,
+            name: 'Bojack Horseman',
+            email: 'SecretariatRulez96@hotmail.com',
+          },
+          {
+            _account_id: 3,
+            name: 'Pinky Penguin',
+          },
+        ],
+        CC: [
+          {
+            _account_id: 4,
+            name: 'Diane Nguyen',
+            email: 'macarthurfellow2B@juno.com',
+          },
+          {
+            email: 'test@e.mail',
+          },
+        ],
+      },
+      removable_reviewers: [
+        {
+          _account_id: 3,
+          name: 'Pinky Penguin',
+        },
+        {
+          _account_id: 4,
+          name: 'Diane Nguyen',
+          email: 'macarthurfellow2B@juno.com',
+        },
+        {
+          email: 'test@e.mail',
+        },
+      ],
+    };
+    flush();
+    const chips =
+        element.root.querySelectorAll('gr-account-chip');
+    assert.equal(chips.length, 4);
+
+    for (const el of Array.from(chips)) {
+      const accountID = el.account._account_id || el.account.email;
+      assert.ok(accountID);
+
+      const buttonEl = el.shadowRoot
+          .querySelector('gr-button');
+      assert.isNotNull(buttonEl);
+      if (accountID == 2) {
+        assert.isTrue(buttonEl.hasAttribute('hidden'));
+      } else {
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
+      }
+    }
+  });
+
+  suite('_handleRemove', () => {
+    let removeReviewerStub;
+    let reviewersChangedSpy;
+
+    const reviewerWithId = {
+      _account_id: 2,
+      name: 'Some name',
+    };
+
+    const reviewerWithIdAndEmail = {
+      _account_id: 4,
+      name: 'Some other name',
+      email: 'example@',
+    };
+
+    const reviewerWithEmailOnly = {
+      email: 'example2@example',
+    };
+
+    let chips;
+
+    setup(() => {
+      removeReviewerStub = sinon
+          .stub(element, '_removeReviewer')
+          .returns(Promise.resolve(new Response({status: 200})));
+      element.mutable = true;
+
+      const allReviewers = [
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ];
+
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          REVIEWER: allReviewers,
+        },
+        removable_reviewers: allReviewers,
+      };
+      flush();
+      chips = Array.from(element.root.querySelectorAll('gr-account-chip'));
+      assert.equal(chips.length, allReviewers.length);
+      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
+    });
+
+    test('_handleRemove for account with accountId only', async () => {
+      const accountChip = chips.find(chip =>
+        chip.account._account_id === reviewerWithId._account_id
+      );
+      accountChip._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with accountId and email', async () => {
+      const accountChip = chips.find(chip =>
+        chip.account._account_id === reviewerWithIdAndEmail._account_id
+      );
+      accountChip._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(
+          removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with email only', async () => {
+      const accountChip = chips.find(
+          chip => chip.account.email === reviewerWithEmailOnly.email
+      );
+      accountChip._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+      ]);
+    });
+  });
+
+  test('tracking reviewers and ccs', () => {
+    let counter = 0;
+    function makeAccount() {
+      return {_account_id: counter++};
+    }
+
+    const owner = makeAccount();
+    const reviewer = makeAccount();
+    const cc = makeAccount();
+    const reviewers = {
+      REMOVED: [makeAccount()],
+      REVIEWER: [owner, reviewer],
+      CC: [owner, cc],
+    };
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer, cc]);
+
+    element.reviewersOnly = true;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer]);
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [cc]);
+  });
+
+  test('_handleAddTap passes mode with event', () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    const e = {preventDefault() {}};
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {
+      reviewersOnly: false,
+      ccsOnly: false,
+    }});
+
+    element.reviewersOnly = true;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual(
+        fireStub.lastCall.args[0].detail,
+        {value: {reviewersOnly: true, ccsOnly: false}});
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual(fireStub.lastCall.args[0].detail,
+        {value: {ccsOnly: true, reviewersOnly: false}});
+  });
+
+  test('dont show all reviewers button with 4 reviewers', () => {
+    const reviewers = [];
+    element.maxReviewersDisplayed = 3;
+    for (let i = 0; i < 4; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 4);
+    assert.equal(element._reviewers.length, 4);
+    assert.isTrue(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('show all reviewers button with 9 reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 9; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 3);
+    assert.equal(element._displayedReviewers.length, 6);
+    assert.equal(element._reviewers.length, 9);
+    assert.isFalse(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('show all reviewers button', () => {
+    const reviewers = [];
+    for (let i = 0; i < 100; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 94);
+    assert.equal(element._displayedReviewers.length, 6);
+    assert.equal(element._reviewers.length, 100);
+    assert.isFalse(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.hiddenReviewers'));
+
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 100);
+    assert.equal(element._reviewers.length, 100);
+    assert.isTrue(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('votable labels', () => {
+    const change = {
+      labels: {
+        Foo: {
+          all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
+        },
+        Bar: {
+          all: [{_account_id: 1, permitted_voting_range: {max: 1}},
+            {_account_id: 7, permitted_voting_range: {max: 1}}],
+        },
+        FooBar: {
+          all: [{_account_id: 7, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 1}, change),
+        'Bar');
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 7}, change),
+        'Foo: +2, Bar, FooBar');
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 2}, change),
+        '');
+  });
+
+  test('fails gracefully when all is not included', () => {
+    const change = {
+      labels: {Foo: {}},
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+      },
+    };
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 1}, change), '');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
deleted file mode 100644
index 44ec9b0..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ /dev/null
@@ -1,240 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-import {NO_THREADS_MSG} from '../../../constants/messages.js';
-
-/**
- * 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,
-      changeNum: String,
-      loggedIn: Boolean,
-      _sortedThreads: {
-        type: Array,
-      },
-      _filteredThreads: {
-        type: Array,
-        computed: '_computeFilteredThreads(_sortedThreads, ' +
-          '_unresolvedOnly, _draftsOnly,' +
-          'onlyShowRobotCommentsWithHumanReply)',
-      },
-      _unresolvedOnly: {
-        type: Boolean,
-        value: false,
-      },
-      _draftsOnly: {
-        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,
-      },
-    };
-  }
-
-  static get observers() { return ['_computeSortedThreads(threads.*)']; }
-
-  _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);
-  }
-
-  // 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; }
-          }
-
-          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;
-        }
-        return c;
-      }
-    }).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 || lastComment.__date,
-    };
-  }
-
-  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.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
new file mode 100644
index 0000000..6a32834
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -0,0 +1,449 @@
+/**
+ * @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.
+ */
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/shared-styles';
+import '../../shared/gr-comment-thread/gr-comment-thread';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-thread-list_html';
+import {parseDate} from '../../../utils/date-util';
+
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+  PolymerSpliceChange,
+  PolymerDeepPropertyChange,
+} from '@polymer/polymer/interfaces';
+import {ChangeInfo} from '../../../types/common';
+import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util';
+
+interface CommentThreadWithInfo {
+  thread: CommentThread;
+  hasRobotComment: boolean;
+  hasHumanReplyToRobotComment: boolean;
+  unresolved: boolean;
+  isEditing: boolean;
+  hasDraft: boolean;
+  updated?: Date;
+}
+
+@customElement('gr-thread-list')
+export class GrThreadList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Array})
+  threads: CommentThread[] = [];
+
+  @property({type: String})
+  changeNum?: string;
+
+  @property({type: Boolean})
+  loggedIn?: boolean;
+
+  @property({type: Array})
+  _sortedThreads: CommentThread[] = [];
+
+  @property({
+    computed:
+      '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
+      '_draftsOnly, onlyShowRobotCommentsWithHumanReply)',
+    type: Array,
+  })
+  _displayedThreads: CommentThread[] = [];
+
+  // thread-list is used in multiple places like the change log, hence
+  // keeping the default to be false. When used in comments tab, it's
+  // set as true.
+  @property({type: Boolean})
+  unresolvedOnly = false;
+
+  @property({type: Boolean})
+  _draftsOnly = false;
+
+  @property({type: Boolean})
+  onlyShowRobotCommentsWithHumanReply = false;
+
+  @property({type: Boolean})
+  hideToggleButtons = false;
+
+  _computeShowDraftToggle(loggedIn?: boolean) {
+    return loggedIn ? 'show' : '';
+  }
+
+  _showEmptyThreadsMessage(
+    threads: CommentThread[],
+    displayedThreads: CommentThread[],
+    unresolvedOnly: boolean
+  ) {
+    if (!threads || !displayedThreads) return false;
+    return !threads.length || (unresolvedOnly && !displayedThreads.length);
+  }
+
+  _computeEmptyThreadsMessage(threads: CommentThread[]) {
+    return !threads.length ? 'No comments.' : 'No unresolved comments';
+  }
+
+  _showPartyPopper(threads: CommentThread[]) {
+    return !!threads.length;
+  }
+
+  _computeResolvedCommentsMessage(
+    threads: CommentThread[],
+    displayedThreads: CommentThread[],
+    unresolvedOnly: boolean
+  ) {
+    if (unresolvedOnly && threads.length && !displayedThreads.length) {
+      return (
+        `Show ${threads.length} resolved comment` +
+        (threads.length > 1 ? 's' : '')
+      );
+    }
+    return '';
+  }
+
+  _showResolvedCommentsButton(
+    threads: CommentThread[],
+    displayedThreads: CommentThread[],
+    unresolvedOnly: boolean
+  ) {
+    return unresolvedOnly && threads.length && !displayedThreads.length;
+  }
+
+  _handleResolvedCommentsMessageClick() {
+    this.unresolvedOnly = !this.unresolvedOnly;
+  }
+
+  _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
+    if (c1.thread.path !== c2.thread.path) {
+      // '/PATCHSET' will not come before '/COMMIT' when sorting
+      // alphabetically so move it to the front explicitly
+      if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+        return -1;
+      }
+      if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+        return 1;
+      }
+      return c1.thread.path.localeCompare(c2.thread.path);
+    }
+
+    // Patchset comments have no line/range associated with them
+    if (c1.thread.line !== c2.thread.line) {
+      if (!c1.thread.line || !c2.thread.line) {
+        // one of them is a file level comment, show first
+        return c1.thread.line ? 1 : -1;
+      }
+      return c1.thread.line < c2.thread.line ? -1 : 1;
+    }
+
+    if (c1.thread.patchNum !== c2.thread.patchNum) {
+      if (!c1.thread.patchNum) return 1;
+      if (!c2.thread.patchNum) return -1;
+      // TODO(TS): Explicit comparison for 'edit' and 'PARENT' missing?
+      return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
+    }
+
+    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 (c2.updated !== c1.updated) {
+      if (!c1.updated) return 1;
+      if (!c2.updated) return -1;
+      return c2.updated.getTime() - c1.updated.getTime();
+    }
+
+    if (c2.thread.rootId !== c1.thread.rootId) {
+      if (!c1.thread.rootId) return 1;
+      if (!c2.thread.rootId) return -1;
+      return c1.thread.rootId.localeCompare(c2.thread.rootId);
+    }
+
+    return 0;
+  }
+
+  /**
+   * Observer on threads and update _sortedThreads when needed.
+   * Order as follows:
+   * - Patchset level threads (descending based on patchset number)
+   * - unresolved
+   * - comments with drafts
+   * - comments without drafts
+   * - resolved
+   * - comments with drafts
+   * - comments without drafts
+   * - File name
+   * - Line number
+   * - Unresolved (descending based on patchset number)
+   * - comments with drafts
+   * - comments without drafts
+   * - Resolved (descending based on patchset number)
+   * - comments with drafts
+   * - comments without drafts
+   *
+   * @param threads
+   * @param spliceRecord
+   */
+  @observe('threads', 'threads.splices')
+  _updateSortedThreads(
+    threads: CommentThread[],
+    _: PolymerSpliceChange<CommentThread[]>
+  ) {
+    if (!threads || threads.length === 0) {
+      this._sortedThreads = [];
+      this._displayedThreads = [];
+      return;
+    }
+    // We only want to sort on thread additions / removals to avoid
+    // re-rendering on modifications (add new reply / edit draft etc.).
+    // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
+    // TODO(TS): We have removed a buggy check of the splices here. A splice
+    // with addedCount > 0 or removed.length > 0 should also cause re-sorting
+    // and re-rendering, but apparently spliceRecord is always undefined for
+    // whatever reason.
+    if (this._sortedThreads.length === threads.length) {
+      // Instead of replacing the _sortedThreads which will trigger a re-render,
+      // we override all threads inside of it.
+
+      for (const thread of threads) {
+        const idxInSortedThreads = this._sortedThreads.findIndex(
+          t => t.rootId === thread.rootId
+        );
+        this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
+      }
+      return;
+    }
+
+    const threadsWithInfo = threads.map(thread =>
+      this._getThreadWithStatusInfo(thread)
+    );
+    this._sortedThreads = threadsWithInfo
+      .sort((t1, t2) => this._compareThreads(t1, t2))
+      .map(threadInfo => threadInfo.thread);
+  }
+
+  _computeDisplayedThreads(
+    sortedThreadsRecord?: PolymerDeepPropertyChange<
+      CommentThread[],
+      CommentThread[]
+    >,
+    unresolvedOnly?: boolean,
+    draftsOnly?: boolean,
+    onlyShowRobotCommentsWithHumanReply?: boolean
+  ) {
+    if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
+    return sortedThreadsRecord.base.filter(t =>
+      this._shouldShowThread(
+        t,
+        unresolvedOnly,
+        draftsOnly,
+        onlyShowRobotCommentsWithHumanReply
+      )
+    );
+  }
+
+  _isFirstThreadWithFileName(
+    displayedThreads: CommentThread[],
+    thread: CommentThread,
+    unresolvedOnly?: boolean,
+    draftsOnly?: boolean,
+    onlyShowRobotCommentsWithHumanReply?: boolean
+  ) {
+    const threads = displayedThreads.filter(t =>
+      this._shouldShowThread(
+        t,
+        unresolvedOnly,
+        draftsOnly,
+        onlyShowRobotCommentsWithHumanReply
+      )
+    );
+    const index = threads.findIndex(t => t.rootId === thread.rootId);
+    if (index === -1) {
+      return false;
+    }
+    return index === 0 || threads[index - 1].path !== threads[index].path;
+  }
+
+  _shouldRenderSeparator(
+    displayedThreads: CommentThread[],
+    thread: CommentThread,
+    unresolvedOnly?: boolean,
+    draftsOnly?: boolean,
+    onlyShowRobotCommentsWithHumanReply?: boolean
+  ) {
+    const threads = displayedThreads.filter(t =>
+      this._shouldShowThread(
+        t,
+        unresolvedOnly,
+        draftsOnly,
+        onlyShowRobotCommentsWithHumanReply
+      )
+    );
+    const index = threads.findIndex(t => t.rootId === thread.rootId);
+    if (index === -1) {
+      return false;
+    }
+    return (
+      index > 0 &&
+      this._isFirstThreadWithFileName(
+        displayedThreads,
+        thread,
+        unresolvedOnly,
+        draftsOnly,
+        onlyShowRobotCommentsWithHumanReply
+      )
+    );
+  }
+
+  _shouldShowThread(
+    thread: CommentThread,
+    unresolvedOnly?: boolean,
+    draftsOnly?: boolean,
+    onlyShowRobotCommentsWithHumanReply?: boolean
+  ) {
+    if (
+      [
+        thread,
+        unresolvedOnly,
+        draftsOnly,
+        onlyShowRobotCommentsWithHumanReply,
+      ].includes(undefined)
+    ) {
+      return false;
+    }
+
+    if (
+      !draftsOnly &&
+      !unresolvedOnly &&
+      !onlyShowRobotCommentsWithHumanReply
+    ) {
+      return true;
+    }
+
+    const threadInfo = this._getThreadWithStatusInfo(thread);
+
+    if (threadInfo.isEditing) {
+      return true;
+    }
+
+    if (
+      threadInfo.hasRobotComment &&
+      onlyShowRobotCommentsWithHumanReply &&
+      !threadInfo.hasHumanReplyToRobotComment
+    ) {
+      return false;
+    }
+
+    let filtersCheck = true;
+    if (draftsOnly && unresolvedOnly) {
+      filtersCheck = threadInfo.hasDraft && threadInfo.unresolved;
+    } else if (draftsOnly) {
+      filtersCheck = threadInfo.hasDraft;
+    } else if (unresolvedOnly) {
+      filtersCheck = threadInfo.unresolved;
+    }
+
+    return filtersCheck;
+  }
+
+  _getThreadWithStatusInfo(thread: CommentThread): CommentThreadWithInfo {
+    const comments = thread.comments;
+    const lastComment = comments.length
+      ? comments[comments.length - 1]
+      : undefined;
+    let hasRobotComment = false;
+    let hasHumanReplyToRobotComment = false;
+    comments.forEach(comment => {
+      if ((comment as UIRobot).robot_id) {
+        hasRobotComment = true;
+      } else if (hasRobotComment) {
+        hasHumanReplyToRobotComment = true;
+      }
+    });
+    let updated = undefined;
+    if (lastComment) {
+      if (isDraft(lastComment)) updated = lastComment.__date;
+      if (lastComment.updated) updated = parseDate(lastComment.updated);
+    }
+
+    return {
+      thread,
+      hasRobotComment,
+      hasHumanReplyToRobotComment,
+      unresolved: !!lastComment && !!lastComment.unresolved,
+      isEditing: !!lastComment && !!lastComment.__editing,
+      hasDraft: !!lastComment && isDraft(lastComment),
+      updated,
+    };
+  }
+
+  removeThread(rootId: string) {
+    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: CustomEvent) {
+    this.removeThread(e.detail.rootId);
+  }
+
+  _handleCommentsChanged(e: CustomEvent) {
+    this.dispatchEvent(
+      new CustomEvent('thread-list-modified', {
+        detail: {rootId: e.detail.rootId, path: e.detail.path},
+      })
+    );
+  }
+
+  _isOnParent(side?: CommentSide) {
+    // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
+    // classified as parent??
+    return !!side;
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapUnresolvedToggle(e: Event) {
+    e.preventDefault();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-thread-list': 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
deleted file mode 100644
index e48fe97..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
new file mode 100644
index 0000000..e55f98a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -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';
+
+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;
+    }
+    .thread-separator {
+      border-top: 1px solid var(--border-color);
+      margin-top: var(--spacing-xl);
+    }
+    .resolved-comments-message {
+      color: var(--link-color);
+      cursor: pointer;
+    }
+    .show-resolved-comments {
+      box-shadow: none;
+      padding-left: var(--spacing-m);
+    }
+  </style>
+  <template is="dom-if" if="[[!hideToggleButtons]]">
+    <div class="header">
+      <div class="toggleItem">
+        <paper-toggle-button
+          id="unresolvedToggle"
+          checked="{{!unresolvedOnly}}"
+          on-tap="_onTapUnresolvedToggle"
+          >All comments</paper-toggle-button
+        >
+      </div>
+      <div
+        class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
+      >
+        <paper-toggle-button
+          id="draftToggle"
+          checked="{{_draftsOnly}}"
+          on-tap="_onTapUnresolvedToggle"
+          >Comments with drafts</paper-toggle-button
+        >
+      </div>
+    </div>
+  </template>
+  <div id="threads">
+    <template
+      is="dom-if"
+      if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
+    >
+      <div>
+        <span>
+          <template is="dom-if" if="[[_showPartyPopper(threads)]]">
+            <span> \&#x1F389 </span>
+          </template>
+          [[_computeEmptyThreadsMessage(threads, _displayedThreads,
+          unresolvedOnly)]]
+          <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
+            <gr-button
+              class="show-resolved-comments"
+              link
+              on-click="_handleResolvedCommentsMessageClick">
+                [[_computeResolvedCommentsMessage(threads, _displayedThreads,
+                unresolvedOnly)]]
+            </gr-button>
+          </template>
+        </span>
+      </div>
+    </template>
+    <template
+      is="dom-repeat"
+      items="[[_displayedThreads]]"
+      as="thread"
+      initial-count="10"
+      target-framerate="60"
+    >
+      <template
+        is="dom-if"
+        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+      >
+        <div class="thread-separator"></div>
+      </template>
+      <gr-comment-thread
+        show-file-path=""
+        change-num="[[changeNum]]"
+        comments="[[thread.comments]]"
+        comment-side="[[thread.commentSide]]"
+        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        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
deleted file mode 100644
index 4b00d5a..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
+++ /dev/null
@@ -1,404 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-thread-list></gr-thread-list>
-  </template>
-</test-fixture>
-
-<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.onlyShowRobotCommentsWithHumanReply = true;
-    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,
-          },
-          {
-            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',
-            },
-            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',
-            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',
-            },
-            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: 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',
-            },
-            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();
-      });
-    });
-
-    test('toggle buttons are hidden', () => {
-      assert.equal(element.shadowRoot.querySelector('.header').style.display,
-          'none');
-    });
-  });
-
-  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-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
new file mode 100644
index 0000000..efc072f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -0,0 +1,635 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-thread-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+
+const basicFixture = fixtureFromElement('gr-thread-list');
+
+suite('gr-thread-list tests', () => {
+  let element;
+
+  let threadElements;
+
+  function getVisibleThreads() {
+    return [...dom(element.root)
+        .querySelectorAll('gr-comment-thread')]
+        .filter(e => e.style.display !== 'none');
+  }
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    element.changeNum = 123;
+    element.change = {
+      project: 'testRepo',
+    };
+    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,
+          },
+          {
+            id: '503008e2_0ab203ee',
+            path: '/COMMIT_MSG',
+            line: 5,
+            in_reply_to: 'ecf0b9fa_fe1a5f62',
+            updated: '2018-02-13 22:48:48.018000000',
+            message: 'draft',
+            unresolved: true,
+            __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',
+            },
+            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',
+            updated: '2018-02-13 22:48:40.000000000',
+            message: 'Another unresolved comment',
+            unresolved: false,
+          },
+        ],
+        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',
+            },
+            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.m683trwff69',
+            __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: [
+          {
+            id: 'patchset_level_1',
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2018-02-15 22:48:48.018000000',
+            message: 'patchset comment 1',
+            unresolved: false,
+            __editing: false,
+            patch_set: '2',
+          },
+        ],
+        patchNum: 2,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_1',
+        start_datetime: '2018-02-09 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_2',
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2018-02-15 22:48:48.018000000',
+            message: 'patchset comment 2',
+            unresolved: false,
+            __editing: false,
+            patch_set: '3',
+          },
+        ],
+        patchNum: 3,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_2',
+        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: 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',
+            },
+            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',
+      },
+    ];
+
+    // use flush to render all (bypass initial-count set on dom-repeat)
+    flush(() => {
+      threadElements = dom(element.root)
+          .querySelectorAll('gr-comment-thread');
+      done();
+    });
+  });
+
+  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('show all threads by default', () => {
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, element.threads.length);
+    assert.equal(getVisibleThreads().length, element.threads.length);
+  });
+
+  test('show unresolved threads if unresolvedOnly is set', done => {
+    element.unresolvedOnly = true;
+    flush();
+    const unresolvedThreads = element.threads.filter(t => t.comments.some(
+        c => c.unresolved
+    ));
+    assert.equal(getVisibleThreads().length, unresolvedThreads.length);
+    done();
+  });
+
+  test('showing file name takes visible threads into account', () => {
+    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
+        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
+        element.onlyShowRobotCommentsWithHumanReply), true);
+    element.unresolvedOnly = true;
+    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
+        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
+        element.onlyShowRobotCommentsWithHumanReply), false);
+  });
+
+  test('onlyShowRobotCommentsWithHumanReply ', () => {
+    element.onlyShowRobotCommentsWithHumanReply = true;
+    flush();
+    assert.equal(
+        getVisibleThreads().length,
+        element.threads.length - 1);
+    assert.isNotOk(getVisibleThreads().find(th => th.rootId === 'rc1'));
+  });
+
+  suite('_compareThreads', () => {
+    test('patchset comes before any other file', () => {
+      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
+      const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
+
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+
+      // assigning values to properties such that t2 should come first
+      t1.patchNum = 1;
+      t2.patchNum = 2;
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('file path is compared lexicographically', () => {
+      const t1 = {thread: {path: 'a.txt'}};
+      const t2 = {thread: {path: 'b.txt'}};
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+
+      t1.patchNum = 1;
+      t2.patchNum = 2;
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('patchset comments sorted by reverse patchset', () => {
+      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 1}};
+      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 2}};
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+    });
+
+    test('patchset comments with same patchset picks unresolved first', () => {
+      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 1}, unresolved: true};
+      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 1}, unresolved: false};
+      t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('file level comment before line', () => {
+      const t1 = {thread: {path: 'a.txt', line: 2}};
+      const t2 = {thread: {path: 'a.txt'}};
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+
+      // give preference to t1 in unresolved/draft properties
+      t1.unresolved = t1.hasDraft = true;
+      t2.unresolved = t2.unresolved = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+    });
+
+    test('comments sorted by line', () => {
+      const t1 = {thread: {path: 'a.txt', line: 2}};
+      const t2 = {thread: {path: 'a.txt', line: 3}};
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('comments on same line sorted by reverse patchset', () => {
+      const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1}};
+      const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 2}};
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+
+      // give preference to t1 in unresolved/draft properties
+      t1.unresolved = t1.hasDraft = true;
+      t2.unresolved = t2.unresolved = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+    });
+
+    test('comments on same line & patchset sorted by unresolved first',
+        () => {
+          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: true};
+          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: false};
+          t1.patchNum = t2.patchNum = 1;
+          assert.equal(element._compareThreads(t1, t2), -1);
+          assert.equal(element._compareThreads(t2, t1), 1);
+
+          t2.hasDraft = true;
+          t1.hasDraft = false;
+          assert.equal(element._compareThreads(t1, t2), -1);
+          assert.equal(element._compareThreads(t2, t1), 1);
+        });
+
+    test('comments on same line & patchset & unresolved sorted by draft',
+        () => {
+          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: true, hasDraft: false};
+          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: true, hasDraft: true};
+          t1.patchNum = t2.patchNum = 1;
+          assert.equal(element._compareThreads(t1, t2), 1);
+          assert.equal(element._compareThreads(t2, t1), -1);
+        });
+  });
+
+  test('_computeSortedThreads', () => {
+    assert.equal(element._sortedThreads.length, 9);
+    const expectedSortedRootIds = [
+      'patchset_level_2', // Posted on Patchset 3
+      'patchset_level_1', // Posted on Patchset 2
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      'rc2', // Line 7 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('thread removal and sort again', () => {
+    threadElements[1].dispatchEvent(
+        new CustomEvent('thread-discard', {
+          detail: {rootId: 'rc2'},
+          composed: true, bubbles: true,
+        }));
+    flush();
+    assert.equal(element._sortedThreads.length, 8);
+    const expectedSortedRootIds = [
+      'patchset_level_2',
+      'patchset_level_1',
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('modification on thread shold not trigger sort again', () => {
+    const currentSortedThreads = [...element._sortedThreads];
+    for (const thread of currentSortedThreads) {
+      thread.comments = [...thread.comments];
+    }
+    const modifiedThreads = [...element.threads];
+    modifiedThreads[5] = {...modifiedThreads[5]};
+    modifiedThreads[5].comments = [...modifiedThreads[5].comments, {
+      ...modifiedThreads[5].comments[0],
+      unresolved: false,
+    }];
+    element.threads = modifiedThreads;
+    assert.notDeepEqual(currentSortedThreads, element._sortedThreads);
+
+    // exact same order as in _computeSortedThreads
+    const expectedSortedRootIds = [
+      'patchset_level_2',
+      'patchset_level_1',
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      'rc2', // Line 7 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('reset sortedThreads when threads set to undefiend', () => {
+    element.threads = undefined;
+    assert.deepEqual(element._sortedThreads, []);
+  });
+
+  test('non-equal length of sortThreads and threads' +
+    ' should trigger sort again', () => {
+    const modifiedThreads = [...element.threads];
+    const currentSortedThreads = [...element._sortedThreads];
+    element._sortedThreads = [];
+    element.threads = modifiedThreads;
+    assert.deepEqual(currentSortedThreads, element._sortedThreads);
+
+    // exact same order as in _computeSortedThreads
+    const expectedSortedRootIds = [
+      'patchset_level_2',
+      'patchset_level_1',
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      'rc2', // Line 7 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('toggle unresolved shows all comments', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedToggle'));
+    flush();
+    assert.equal(getVisibleThreads().length, 4);
+  });
+
+  test('toggle drafts only shows threads with draft comments', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    flush();
+    assert.equal(getVisibleThreads().length, 2);
+  });
+
+  test('toggle drafts and unresolved should ignore comments in editing', () => {
+    const modifiedThreads = [...element.threads];
+    modifiedThreads[5] = {...modifiedThreads[5]};
+    modifiedThreads[5].comments = [...modifiedThreads[5].comments];
+    modifiedThreads[5].comments.push({
+      ...modifiedThreads[5].comments[0],
+      __editing: true,
+    });
+    element.threads = modifiedThreads;
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedToggle'));
+    flush();
+    assert.equal(getVisibleThreads().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'));
+    flush();
+    assert.equal(getVisibleThreads().length, 1);
+  });
+
+  test('modification events are consumed and displatched', () => {
+    sinon.spy(element, '_handleCommentsChanged');
+    const dispatchSpy = sinon.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();
+      });
+    });
+
+    test('toggle buttons are hidden', () => {
+      assert.equal(element.shadowRoot.querySelector('.header').style.display,
+          'none');
+    });
+  });
+
+  suite('empty thread', () => {
+    setup(done => {
+      element.threads = [];
+      flush(() => {
+        done();
+      });
+    });
+
+    test('default empty message should show', () => {
+      assert.isTrue(
+          element.shadowRoot.querySelector('#threads').textContent.trim()
+              .includes('No comments.'));
+    });
+  });
+});
+
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
deleted file mode 100644
index 9171908..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
+++ /dev/null
@@ -1,153 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
-const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
-
-// Command names correspond to download plugin definitions.
-const PREFERRED_FETCH_COMMAND_ORDER = [
-  'checkout',
-  'cherry pick',
-  'pull',
-];
-
-/**
- * @extends Polymer.Element
- */
-class GrUploadHelpDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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: {
-        type: String,
-        value: COMMIT_COMMAND,
-        readOnly: true,
-      },
-      _fetchCommand: {
-        type: String,
-        computed: '_computeFetchCommand(revision, ' +
-          '_preferredDownloadCommand, _preferredDownloadScheme)',
-      },
-      _preferredDownloadCommand: String,
-      _preferredDownloadScheme: String,
-      _pushCommand: {
-        type: String,
-        computed: '_computePushCommand(targetBranch)',
-      },
-    };
-  }
-
-  /** @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;
-          }
-        });
-  }
-
-  _handleCloseTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('close', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _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; }
-
-    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) {
-      return;
-    }
-
-    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]];
-      }
-    }
-
-    return undefined;
-  }
-
-  _computePushCommand(targetBranch) {
-    return PUSH_COMMAND_PREFIX + targetBranch;
-  }
-}
-
-customElements.define(GrUploadHelpDialog.is, GrUploadHelpDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
new file mode 100644
index 0000000..cab17dd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
@@ -0,0 +1,149 @@
+/**
+ * @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.
+ */
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-shell-command/gr-shell-command';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-upload-help-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {RevisionInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
+const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
+
+// Command names correspond to download plugin definitions.
+const PREFERRED_FETCH_COMMAND_ORDER = ['checkout', 'cherry pick', 'pull'];
+
+export interface GrUploadHelpDialog {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-upload-help-dialog')
+export class GrUploadHelpDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the user presses the close button.
+   *
+   * @event close
+   */
+
+  @property({type: Object})
+  revision?: RevisionInfo;
+
+  @property({type: String})
+  targetBranch?: string;
+
+  @property({type: String})
+  _commitCommand = COMMIT_COMMAND;
+
+  @property({
+    type: String,
+    computed: '_computeFetchCommand(revision, _preferredDownloadScheme)',
+  })
+  _fetchCommand?: string;
+
+  @property({type: String})
+  _preferredDownloadScheme?: string;
+
+  @property({type: String, computed: '_computePushCommand(targetBranch)'})
+  _pushCommand?: string;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI
+      .getLoggedIn()
+      .then(loggedIn =>
+        loggedIn ? this.$.restAPI.getPreferences() : Promise.resolve(undefined)
+      )
+      .then(prefs => {
+        if (prefs) {
+          // TODO(TS): The download_command pref was deleted in change 249223.
+          // this._preferredDownloadCommand = prefs.download_command;
+          this._preferredDownloadScheme = prefs.download_scheme;
+        }
+      });
+  }
+
+  _handleCloseTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('close', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _computeFetchCommand(
+    revision?: RevisionInfo,
+    scheme?: string
+  ): string | undefined {
+    if (!revision || !revision.fetch) return undefined;
+    if (!scheme) {
+      const keys = Object.keys(revision.fetch).sort();
+      if (keys.length === 0) {
+        return undefined;
+      }
+      scheme = keys[0];
+    }
+    if (
+      !scheme ||
+      !revision.fetch[scheme] ||
+      !revision.fetch[scheme].commands
+    ) {
+      return undefined;
+    }
+
+    const cmds: {[key: string]: string} = {};
+    Object.entries(revision.fetch[scheme].commands!).forEach(([key, cmd]) => {
+      cmds[key.toLowerCase()] = cmd;
+    });
+
+    // 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;
+  }
+
+  _computePushCommand(targetBranch: string) {
+    return PUSH_COMMAND_PREFIX + targetBranch;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-upload-help-dialog': 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
deleted file mode 100644
index ec010a1..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
new file mode 100644
index 0000000..1ee3a3a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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
deleted file mode 100644
index 164c483..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
+++ /dev/null
@@ -1,124 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-upload-help-dialog></gr-upload-help-dialog>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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');
-  });
-
-  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('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'));
-
-      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'));
-    });
-
-    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('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/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js
new file mode 100644
index 0000000..005b20d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js
@@ -0,0 +1,97 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-upload-help-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-upload-help-dialog');
+
+suite('gr-upload-help-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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');
+  });
+
+  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('revision not defined', () => {
+      assert.isUndefined(
+          element._computeFetchCommand(undefined, ''));
+    });
+
+    test('insufficiently defined scheme', () => {
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, 'badscheme'));
+
+      const rev = {...testRev};
+      rev.fetch = {...testRev.fetch, nocmds: {commands: {}}};
+      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 command', () => {
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, 'http'),
+          'http checkout');
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, 'ssh'),
+          'ssh pull');
+    });
+  });
+});
+
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
deleted file mode 100644
index 2645c63..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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;
-
-/**
- * @extends Polymer.Element
- */
-class GrAccountDropdown extends mixinBehaviors( [
-  DisplayNameBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-account-dropdown'; }
-
-  static get properties() {
-    return {
-      account: Object,
-      config: Object,
-      links: {
-        type: Array,
-        computed: '_getLinks(_switchAccountUrl, _path)',
-      },
-      topContent: {
-        type: Array,
-        computed: '_getTopContent(account)',
-      },
-      _path: {
-        type: String,
-        value: '/',
-      },
-      _hasAvatars: Boolean,
-      _switchAccountUrl: String,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._handleLocationChange();
-    this.listen(window, 'location-change', '_handleLocationChange');
-    this.$.restAPI.getConfig().then(cfg => {
-      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);
-    });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'location-change', '_handleLocationChange');
-  }
-
-  _getLinks(switchAccountUrl, path) {
-    // Polymer 2: check for undefined
-    if ([switchAccountUrl, path].some(arg => arg === undefined)) {
-      return undefined;
-    }
-
-    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;
-  }
-
-  _getTopContent(account) {
-    return [
-      {text: this._accountName(account), bold: true},
-      {text: account.email ? account.email : ''},
-    ];
-  }
-
-  _handleLocationChange() {
-    this._path =
-        window.location.pathname +
-        window.location.search +
-        window.location.hash;
-  }
-
-  _interpolateUrl(url, replacements) {
-    return url.replace(
-        INTERPOLATE_URL_PATTERN,
-        (match, p1) => 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.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
new file mode 100644
index 0000000..ef0ced8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -0,0 +1,146 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../shared/gr-avatar/gr-avatar';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-account-dropdown_html';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-dropdown': GrAccountDropdown;
+  }
+}
+
+export interface GrAccountDropdown {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-account-dropdown')
+export class GrAccountDropdown extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  @property({type: Array, computed: '_getLinks(_switchAccountUrl, _path)'})
+  links?: string[];
+
+  @property({type: Array, computed: '_getTopContent(account)'})
+  topContent?: string[];
+
+  @property({type: String})
+  _path = '/';
+
+  @property({type: Boolean})
+  _hasAvatars = false;
+
+  @property({type: String})
+  _switchAccountUrl = '';
+
+  /** @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);
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'location-change', '_handleLocationChange');
+  }
+
+  _getLinks(switchAccountUrl: string, path: string) {
+    // Polymer 2: check for undefined
+    if (switchAccountUrl === undefined || path === undefined) {
+      return undefined;
+    }
+
+    const links = [];
+    links.push({name: 'Settings', url: '/settings/'});
+    links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
+    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;
+  }
+
+  _getTopContent(account?: AccountInfo) {
+    return [
+      {text: this._accountName(account), bold: true},
+      {text: account?.email ? account.email : ''},
+    ];
+  }
+
+  _handleShortcutsTap() {
+    this.dispatchEvent(
+      new CustomEvent('show-keyboard-shortcuts', {
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+
+  _handleLocationChange() {
+    this._path =
+      window.location.pathname + window.location.search + window.location.hash;
+  }
+
+  _interpolateUrl(url: string, replacements: {[key: string]: string}) {
+    return url.replace(
+      INTERPOLATE_URL_PATTERN,
+      (_, p1) => replacements[p1] || ''
+    );
+  }
+
+  _accountName(account?: AccountInfo) {
+    return getUserName(this.config, account);
+  }
+}
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
deleted file mode 100644
index b47894e..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
new file mode 100644
index 0000000..b67e1e8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
@@ -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';
+
+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]]"
+    on-tap-item-shortcuts="_handleShortcutsTap"
+    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
deleted file mode 100644
index 6c8ed68..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ /dev/null
@@ -1,124 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-account-dropdown</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-account-dropdown></gr-account-dropdown>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
new file mode 100644
index 0000000..a8f206c
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-dropdown.js';
+
+const basicFixture = fixtureFromElement('gr-account-dropdown');
+
+suite('gr-account-dropdown tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  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, 3);
+
+    // Unparameterized switch account link.
+    let links = element._getLinks('/switch-account', '');
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
+      name: 'Switch account',
+      url: '/switch-account',
+      external: true,
+    });
+
+    // Parameterized switch account link.
+    links = element._getLinks('/switch-account${path}', '/c/123');
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
+      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');
+  });
+});
+
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
deleted file mode 100644
index 6814d89..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
+++ /dev/null
@@ -1,64 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @extends Polymer.Element */
-class GrErrorDialog extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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'));
-  }
-}
-
-customElements.define(GrErrorDialog.is, GrErrorDialog);
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
new file mode 100644
index 0000000..b28b13e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -0,0 +1,57 @@
+/**
+ * @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.
+ */
+import '../../shared/gr-dialog/gr-dialog';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-error-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-error-dialog': GrErrorDialog;
+  }
+}
+
+@customElement('gr-error-dialog')
+export class GrErrorDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the dismiss button is pressed.
+   *
+   * @event dismiss
+   */
+
+  @property({type: String})
+  text?: string;
+
+  @property({type: String})
+  loginUrl = '/login';
+
+  @property({type: Boolean})
+  showSignInButton = false;
+
+  _handleConfirm() {
+    this.dispatchEvent(new CustomEvent('dismiss'));
+  }
+}
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
deleted file mode 100644
index 39d4f2d..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
new file mode 100644
index 0000000..10476cd
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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
deleted file mode 100644
index bd4991f..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
+++ /dev/null
@@ -1,49 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-error-dialog></gr-error-dialog>
-  </template>
-</test-fixture>
-
-<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);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js
new file mode 100644
index 0000000..ea8f7c5
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js
@@ -0,0 +1,35 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-error-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-error-dialog');
+
+suite('gr-error-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('dismiss tap fires event', done => {
+    element.addEventListener('dismiss', () => { done(); });
+    MockInteractions.tap(element.$.dialog.$.confirm);
+  });
+});
+
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
deleted file mode 100644
index 4b5969a..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ /dev/null
@@ -1,417 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-/* Import 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!
-*/
-
-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';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrErrorManager extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  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} */
-      _alertElement: Object,
-      /** @type {?number} */
-      _hideAlertHandle: Number,
-      _refreshingCredentials: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * The time (in milliseconds) since the most recent credential check.
-       */
-      _lastCredentialCheck: {
-        type: Number,
-        value() { return Date.now(); },
-      },
-
-      loginUrl: {
-        type: String,
-        value: '/login',
-      },
-    };
-  }
-
-  constructor() {
-    super();
-
-    /** @type {!Auth} */
-    this._authService = authService;
-
-    /** @type {?Function} */
-    this._authErrorHandlerDeregistrationHook;
-  }
-
-  /** @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');
-
-    this._authErrorHandlerDeregistrationHook =
-      gerritEventEmitter.on('auth-error',
-          event => {
-            this._handleAuthError(event.message, event.action);
-          });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this._clearHideAlertHandle();
-    this.unlisten(document, 'server-error', '_handleServerError');
-    this.unlisten(document, 'network-error', '_handleNetworkError');
-    this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
-    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-    this.unlisten(document, 'show-error', '_handleShowErrorDialog');
-
-    this._authErrorHandlerDeregistrationHook();
-  }
-
-  _shouldSuppressError(msg) {
-    return msg.includes(TOO_MANY_FILES);
-  }
-
-  _handleAuthRequired() {
-    this._showAuthErrorAlert(
-        'Log in is required to perform that action.', 'Log in.');
-  }
-
-  _handleAuthError(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}`);
-    });
-  }
-
-  _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;
-  }
-
-  _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 = this._createToastAlert();
-    this._alertElement.type = 'AUTH';
-    this._alertElement.show(errorText, actionText,
-        this._createLoginPopup.bind(this));
-
-    this._refreshingCredentials = true;
-    this._requestCheckLoggedIn();
-    if (!document.hidden) {
-      this._handleVisibilityChange();
-    }
-  }
-
-  _createToastAlert() {
-    const el = document.createElement('gr-alert');
-    el.toast = true;
-    return el;
-  }
-
-  _handleVisibilityChange() {
-    // Ignore when the page is transitioning to hidden (or hidden is
-    // undefined).
-    if (document.hidden !== false) { return; }
-
-    // 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();
-
-      // check auth status in case:
-      // - user signed out
-      // - user switched account
-      this._checkSignedIn();
-    }
-  }
-
-  _requestCheckLoggedIn() {
-    this.debounce(
-        'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
-  }
-
-  _checkSignedIn() {
-    this._lastCredentialCheck = Date.now();
-
-    // force to refetch account info
-    this.$.restAPI.invalidateAccountsCache();
-    this._authService.clearCache();
-
-    this.$.restAPI.getLoggedIn().then(isLoggedIn => {
-      // do nothing if its refreshing
-      if (!this._refreshingCredentials) return;
-
-      if (!isLoggedIn) {
-        // check later
-        // 1. guest mode
-        // 2. or signed out
-        // in case #2, auth-error is taken care of separately
-        this._requestCheckLoggedIn();
-      } else {
-        // check account
-        this.$.restAPI.getAccount().then(account => {
-          if (this._refreshingCredentials) {
-            // If the credentials were refreshed but the account is different
-            // then reload the page completely.
-            if (account._account_id !== this.knownAccountId) {
-              this._reloadPage();
-              return;
-            }
-
-            this._handleCredentialRefreshed();
-          }
-        });
-      }
-    });
-  }
-
-  _reloadPage() {
-    window.location.reload();
-  }
-
-  _createLoginPopup() {
-    const left = window.screenLeft +
-        (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.');
-    this.$.noInteractionOverlay.close();
-
-    // Clear the cache for auth
-    this._authService.clearCache();
-  }
-
-  _handleWindowFocus() {
-    this.flushDebouncer('checkLoggedIn');
-  }
-
-  _handleShowErrorDialog(e) {
-    this._showErrorDialog(e.detail.message);
-  }
-
-  _handleDismissErrorDialog() {
-    this.$.errorOverlay.close();
-  }
-
-  _showErrorDialog(message, 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.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
new file mode 100644
index 0000000..7a56d1c
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -0,0 +1,512 @@
+/**
+ * @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 to get Gerrit interface */
+/* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
+import '../gr-error-dialog/gr-error-dialog';
+import '../../shared/gr-alert/gr-alert';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-error-manager_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {appContext} from '../../../services/app-context';
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import {customElement, property} from '@polymer/decorators';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {AuthService} from '../../../services/gr-auth/gr-auth';
+import {EventEmitterService} from '../../../services/gr-event-interface/gr-event-interface';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrErrorDialog} from '../gr-error-dialog/gr-error-dialog';
+import {GrAlert} from '../../shared/gr-alert/gr-alert';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {FetchRequest} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
+import {AccountId} from '../../../types/common';
+
+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';
+
+// Bigger number has higher priority
+const ErrorTypePriority = {
+  [ErrorType.AUTH]: 3,
+  [ErrorType.NETWORK]: 2,
+  [ErrorType.GENERIC]: 1,
+};
+
+interface ErrorMsg {
+  errorText?: string;
+  status?: number;
+  statusText?: string;
+  url?: string;
+  trace?: string | null;
+  tip?: string;
+}
+
+export const __testOnly_ErrorType = ErrorType;
+
+export interface GrErrorManager {
+  $: {
+    noInteractionOverlay: GrOverlay;
+    errorDialog: GrErrorDialog;
+    errorOverlay: GrOverlay;
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-error-manager')
+export class GrErrorManager extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * The ID of the account that was logged in when the app was launched. If
+   * not set, then there was no account at launch.
+   */
+  @property({type: Number})
+  knownAccountId?: AccountId | null;
+
+  @property({type: Object})
+  _alertElement: GrAlert | null = null;
+
+  @property({type: Number})
+  _hideAlertHandle: number | null = null;
+
+  @property({type: Boolean})
+  _refreshingCredentials = false;
+
+  /**
+   * The time (in milliseconds) since the most recent credential check.
+   */
+  @property({type: Number})
+  _lastCredentialCheck: number = Date.now();
+
+  @property({type: String})
+  loginUrl = '/login';
+
+  reporting: ReportingService;
+
+  _authService: AuthService;
+
+  eventEmitter: EventEmitterService;
+
+  _authErrorHandlerDeregistrationHook?: Function;
+
+  constructor() {
+    super();
+
+    this._authService = appContext.authService;
+
+    this.reporting = appContext.reportingService;
+    this.eventEmitter = appContext.eventEmitter;
+  }
+
+  /** @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, 'hide-alert', '_hideAlert');
+    this.listen(document, 'show-error', '_handleShowErrorDialog');
+    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+    this.listen(document, 'show-auth-required', '_handleAuthRequired');
+
+    this._authErrorHandlerDeregistrationHook = this.eventEmitter.on(
+      'auth-error',
+      event => {
+        this._handleAuthError(event.message, event.action);
+      }
+    );
+
+    ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this._clearHideAlertHandle();
+    this.unlisten(document, 'server-error', '_handleServerError');
+    this.unlisten(document, 'network-error', '_handleNetworkError');
+    this.unlisten(document, 'show-alert', '_handleShowAlert');
+    this.unlisten(document, 'hide-alert', '_hideAlert');
+    this.unlisten(document, 'show-error', '_handleShowErrorDialog');
+    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
+
+    if (this._authErrorHandlerDeregistrationHook) {
+      this._authErrorHandlerDeregistrationHook();
+    }
+  }
+
+  _shouldSuppressError(msg: string) {
+    return msg.includes(TOO_MANY_FILES);
+  }
+
+  _handleAuthRequired() {
+    this._showAuthErrorAlert(
+      'Log in is required to perform that action.',
+      'Log in.'
+    );
+  }
+
+  _handleAuthError(msg: string, action: string) {
+    this.$.noInteractionOverlay.open().then(() => {
+      this._showAuthErrorAlert(msg, action);
+    });
+  }
+
+  _handleServerError(
+    e: CustomEvent<{response: Response; request: FetchRequest}>
+  ) {
+    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.info(`server error: ${errorText}`);
+    });
+  }
+
+  _showNotFoundMessageWithTip({
+    status,
+    statusText,
+    errorText,
+    url,
+    trace,
+  }: ErrorMsg) {
+    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,
+        }
+      );
+    });
+  }
+
+  _constructServerErrorMsg({
+    errorText,
+    status,
+    statusText,
+    url,
+    trace,
+    tip,
+  }: ErrorMsg) {
+    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: CustomEvent) {
+    this._showAlert(
+      e.detail.message,
+      e.detail.action,
+      e.detail.callback,
+      e.detail.dismissOnNavigation
+    );
+  }
+
+  _handleNetworkError(e: CustomEvent) {
+    this._showAlert('Server unavailable');
+    console.error(e.detail.error.message);
+  }
+
+  // TODO(dhruvsr): allow less priority alerts to override high priority alerts
+  // In some use cases we may want generic alerts to show along/over errors
+  _canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
+    return ErrorTypePriority[incoming] >= ErrorTypePriority[existing];
+  }
+
+  _showAlert(
+    text: string,
+    actionText?: string,
+    actionCallback?: () => void,
+    dismissOnNavigation?: boolean,
+    type?: ErrorType
+  ) {
+    if (this._alertElement) {
+      // check priority before hiding
+      if (!this._canOverride(type, this._alertElement.type)) return;
+      this._hideAlert();
+    }
+
+    this._clearHideAlertHandle();
+    if (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, actionText, actionCallback);
+    this._alertElement = el;
+    this.fire('iron-announce', {text}, {bubbles: true});
+    this.reporting.reportInteraction('show-alert', {text});
+  }
+
+  _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: string, actionText?: string) {
+    // hide any existing alert like `reload`
+    // as auth error should have the highest priority
+    if (this._alertElement) {
+      this._alertElement.hide();
+    }
+
+    this._alertElement = this._createToastAlert();
+    this._alertElement.type = ErrorType.AUTH;
+    this._alertElement.show(errorText, actionText, () =>
+      this._createLoginPopup()
+    );
+    this.fire('iron-announce', {text: errorText}, {bubbles: true});
+    this._refreshingCredentials = true;
+    this._requestCheckLoggedIn();
+    if (!document.hidden) {
+      this._handleVisibilityChange();
+    }
+  }
+
+  _createToastAlert() {
+    const el = document.createElement('gr-alert');
+    el.toast = true;
+    return el;
+  }
+
+  _handleVisibilityChange() {
+    // Ignore when the page is transitioning to hidden (or hidden is
+    // undefined).
+    if (document.hidden !== false) {
+      return;
+    }
+
+    // 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();
+
+      // check auth status in case:
+      // - user signed out
+      // - user switched account
+      this._checkSignedIn();
+    }
+  }
+
+  _requestCheckLoggedIn() {
+    this.debounce(
+      'checkLoggedIn',
+      this._checkSignedIn,
+      CHECK_SIGN_IN_INTERVAL_MS
+    );
+  }
+
+  _checkSignedIn() {
+    this._lastCredentialCheck = Date.now();
+
+    // force to refetch account info
+    this.$.restAPI.invalidateAccountsCache();
+    this._authService.clearCache();
+
+    this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+      // do nothing if its refreshing
+      if (!this._refreshingCredentials) return;
+
+      if (!isLoggedIn) {
+        // check later
+        // 1. guest mode
+        // 2. or signed out
+        // in case #2, auth-error is taken care of separately
+        this._requestCheckLoggedIn();
+      } else {
+        // check account
+        this.$.restAPI.getAccount().then(account => {
+          if (this._refreshingCredentials) {
+            // If the credentials were refreshed but the account is different
+            // then reload the page completely.
+            if (account?._account_id !== this.knownAccountId) {
+              this._reloadPage();
+              return;
+            }
+
+            this._handleCredentialRefreshed();
+          }
+        });
+      }
+    });
+  }
+
+  _reloadPage() {
+    window.location.reload();
+  }
+
+  _createLoginPopup() {
+    const left = window.screenLeft + (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(
+      getBaseUrl() + '/login/%3FcloseAfterLogin',
+      '_blank',
+      options.join(',')
+    );
+    this.listen(window, 'focus', '_handleWindowFocus');
+  }
+
+  _handleCredentialRefreshed() {
+    this.unlisten(window, 'focus', '_handleWindowFocus');
+    this._refreshingCredentials = false;
+    this._hideAlert();
+    this._showAlert('Credentials refreshed.');
+    this.$.noInteractionOverlay.close();
+
+    // Clear the cache for auth
+    this._authService.clearCache();
+  }
+
+  _handleWindowFocus() {
+    this.flushDebouncer('checkLoggedIn');
+  }
+
+  _handleShowErrorDialog(e: CustomEvent) {
+    this._showErrorDialog(e.detail.message);
+  }
+
+  _handleDismissErrorDialog() {
+    this.$.errorOverlay.close();
+  }
+
+  _showErrorDialog(message: string, options?: {showSignInButton?: boolean}) {
+    this.reporting.reportErrorDialog(message);
+    this.$.errorDialog.text = message;
+    this.$.errorDialog.showSignInButton =
+      !!options && !!options.showSignInButton;
+    this.$.errorOverlay.open();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-error-manager': 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
deleted file mode 100644
index 4d32f24..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
new file mode 100644
index 0000000..1cefb78
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
@@ -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';
+
+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>
+`;
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
deleted file mode 100644
index 8272c6e..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ /dev/null
@@ -1,570 +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">
-<meta charset="utf-8">
-<title>gr-error-manager</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 './gr-error-manager.js';
-void (0);
-</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-error-manager></gr-error-manager>
-  </template>
-</test-fixture>
-
-<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.stub(window, 'fetch')
-          .returns(Promise.resolve({ok: true, status: 204}));
-      element = fixture('basic');
-      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.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.isFalse(showAuthErrorStub.calledOnce);
-        done();
-      });
-    });
-
-    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');
-      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.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(() => Promise.resolve('ZOMG'));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {response: {status: 500, text: textSpy}},
-            composed: true, bubbles: true,
-          }));
-
-      assert.isTrue(textSpy.called);
-      flush(() => {
-        assert.isTrue(showErrorStub.calledOnce);
-        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
-            'Error 500: ZOMG'));
-        done();
-      });
-    });
-
-    test('_constructServerErrorMsg', () => {
-      const errorText = 'change conflicts';
-      const status = 409;
-      const statusText = 'Conflict';
-      const url = '/my/test/url';
-
-      assert.equal(element._constructServerErrorMsg({status}),
-          'Error 409');
-      assert.equal(element._constructServerErrorMsg({status, url}),
-          'Error 409: \nEndpoint: /my/test/url');
-      assert.equal(element.
-          _constructServerErrorMsg({status, statusText, url}),
-      'Error 409 (Conflict): \nEndpoint: /my/test/url');
-      assert.equal(element._constructServerErrorMsg({
-        status,
-        statusText,
-        errorText,
-        url,
-      }), 'Error 409 (Conflict): change conflicts' +
-      '\nEndpoint: /my/test/url');
-      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(
-          () => 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);
-      flush(() => {
-        assert.isFalse(showAlertStub.called);
-        done();
-      });
-    });
-
-    test('show network error', done => {
-      const consoleErrorStub = sandbox.stub(console, 'error');
-      const showAlertStub = sandbox.stub(element, '_showAlert');
-      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(
-            'Server unavailable'));
-        assert.isTrue(consoleErrorStub.calledOnce);
-        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
-        done();
-      });
-    });
-
-    test('show auth refresh toast', done => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
-      const refreshStub = sandbox.stub(element.$.restAPI, 'getAccount',
-          () => Promise.resolve({}));
-      const toastSpy = sandbox.spy(element, '_createToastAlert');
-      const windowOpen = sandbox.stub(window, 'open');
-      const responseText = Promise.resolve('Authentication required\n');
-      // fake failed auth
-      window.fetch.returns(Promise.resolve({status: 403}));
-      element.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(() => {
-          // auth-error fired
-          assert.isTrue(toastSpy.called);
-
-          // toast
-          let toast = toastSpy.lastCall.returnValue;
-          assert.isOk(toast);
-          assert.include(
-              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.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]);
-      assert.isNotOk(element._showAlert.lastCall.args[2]);
-    });
-
-    test('checks stale credentials on visibility change', () => {
-      const refreshStub = sandbox.stub(element,
-          '_checkSignedIn');
-      sandbox.stub(Date, 'now').returns(999999);
-      element._lastCredentialCheck = 0;
-      element._handleVisibilityChange();
-
-      // Since there is no known account, it should not test credentials.
-      assert.isFalse(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 0);
-
-      element.knownAccountId = 123;
-      element._handleVisibilityChange();
-
-      // Should test credentials, since there is a known account.
-      assert.isTrue(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 999999);
-    });
-
-    test('refreshes with same credentials', done => {
-      const accountPromise = Promise.resolve({_account_id: 1234});
-      sandbox.stub(element.$.restAPI, 'getAccount')
-          .returns(accountPromise);
-      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      const handleRefreshStub = sandbox.stub(element,
-          '_handleCredentialRefreshed');
-      const reloadStub = sandbox.stub(element, '_reloadPage');
-
-      element.knownAccountId = 1234;
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isFalse(requestCheckStub.called);
-        assert.isTrue(handleRefreshStub.called);
-        assert.isFalse(reloadStub.called);
-        done();
-      });
-    });
-
-    test('_showAlert hides existing alerts', () => {
-      element._alertElement = element._createToastAlert();
-      const hideStub = sandbox.stub(element, '_hideAlert');
-      element._showAlert();
-      assert.isTrue(hideStub.calledOnce);
-    });
-
-    test('show-error', () => {
-      const openStub = sandbox.stub(element.$.errorOverlay, 'open');
-      const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
-      const reportStub = sandbox.stub(
-          element.$.reporting,
-          'reportErrorDialog'
-      );
-
-      const message = 'test message';
-      element.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.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-error-manager/gr-error-manager_test.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
new file mode 100644
index 0000000..f13276f
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
@@ -0,0 +1,574 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-error-manager.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {__testOnly_ErrorType} from './gr-error-manager.js';
+
+const basicFixture = fixtureFromElement('gr-error-manager');
+
+_testOnly_initGerritPluginApi();
+
+suite('gr-error-manager tests', () => {
+  let element;
+
+  suite('when authed', () => {
+    let toastSpy;
+    let openOverlaySpy;
+
+    setup(() => {
+      sinon.stub(window, 'fetch')
+          .returns(Promise.resolve({ok: true, status: 204}));
+      element = basicFixture.instantiate();
+      element._authService.clearCache();
+      toastSpy = sinon.spy(element, '_createToastAlert');
+      openOverlaySpy = sinon.spy(element.$.noInteractionOverlay, 'open');
+    });
+
+    teardown(() => {
+      toastSpy.getCalls().forEach(call => {
+        call.returnValue.remove();
+      });
+    });
+
+    test('does not show auth error on 403 by default', done => {
+      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('server says no.');
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      flush(() => {
+        assert.isFalse(showAuthErrorStub.calledOnce);
+        done();
+      });
+    });
+
+    test('show auth required for 403 with auth error and not authed before',
+        done => {
+          const showAuthErrorStub = sinon.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');
+      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', () => {
+      sinon.stub(element, '_showAuthErrorAlert');
+      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 = sinon.stub(element, '_showErrorDialog');
+      const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail: {response: {status: 500, text: textSpy}},
+            composed: true, bubbles: true,
+          }));
+
+      assert.isTrue(textSpy.called);
+      flush(() => {
+        assert.isTrue(showErrorStub.calledOnce);
+        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
+            'Error 500: ZOMG'));
+        done();
+      });
+    });
+
+    test('_constructServerErrorMsg', () => {
+      const errorText = 'change conflicts';
+      const status = 409;
+      const statusText = 'Conflict';
+      const url = '/my/test/url';
+
+      assert.equal(element._constructServerErrorMsg({status}),
+          'Error 409');
+      assert.equal(element._constructServerErrorMsg({status, url}),
+          'Error 409: \nEndpoint: /my/test/url');
+      assert.equal(element.
+          _constructServerErrorMsg({status, statusText, url}),
+      'Error 409 (Conflict): \nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+      }), 'Error 409 (Conflict): change conflicts' +
+      '\nEndpoint: /my/test/url');
+      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 = sinon.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 = sinon.stub(element, '_showAlert');
+      const textSpy = sinon.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);
+      flush(() => {
+        assert.isFalse(showAlertStub.called);
+        done();
+      });
+    });
+
+    test('show network error', done => {
+      const consoleErrorStub = sinon.stub(console, 'error');
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      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(
+            'Server unavailable'));
+        assert.isTrue(consoleErrorStub.calledOnce);
+        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
+        done();
+      });
+    });
+
+    test('_canOverride alerts', () => {
+      assert.isFalse(element._canOverride(undefined,
+          __testOnly_ErrorType.AUTH));
+      assert.isFalse(element._canOverride(undefined,
+          __testOnly_ErrorType.NETWORK));
+      assert.isTrue(element._canOverride(undefined,
+          __testOnly_ErrorType.GENERIC));
+      assert.isTrue(element._canOverride(undefined, undefined));
+
+      assert.isTrue(element._canOverride(__testOnly_ErrorType.NETWORK,
+          undefined));
+      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH,
+          undefined));
+      assert.isFalse(element._canOverride(__testOnly_ErrorType.NETWORK,
+          __testOnly_ErrorType.AUTH));
+
+      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH,
+          __testOnly_ErrorType.NETWORK));
+    });
+
+    test('show auth refresh toast', async () => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const refreshStub = sinon.stub(element.$.restAPI, 'getAccount').callsFake(
+          () => Promise.resolve({}));
+      const windowOpen = sinon.stub(window, 'open');
+      const responseText = Promise.resolve('Authentication required\n');
+      // 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);
+      await 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);
+      await flush();
+      // Sometime overlay opens with delay, waiting while open is complete
+      await openOverlaySpy.lastCall.returnValue;
+      // auth-error fired
+      assert.isTrue(toastSpy.called);
+
+      // toast
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          toast.root.textContent, 'Credentials expired.');
+      assert.include(
+          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 = sinon.spy(toast, 'hide');
+
+      // now fake authed
+      window.fetch.returns(Promise.resolve({status: 204}));
+      element._handleWindowFocus();
+      element.flushDebouncer('checkLoggedIn');
+      await 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(
+          toast.root.textContent, 'Credentials refreshed');
+
+      // close overlay
+      assert.isTrue(noInteractionOverlay.close.called);
+    });
+
+    test('auth toast should dismiss existing toast', async () => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      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,
+          }));
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          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);
+      await flush();
+      // here needs two flush as there are two chained
+      // promises on server-error handler and flush only flushes one
+      assert.equal(window.fetch.callCount, 2);
+      await flush();
+      // Sometime overlay opens with delay, waiting while open is complete
+      await openOverlaySpy.lastCall.returnValue;
+      // toast
+      toast = toastSpy.lastCall.returnValue;
+      assert.include(
+          toast.root.textContent, 'Credentials expired.');
+      assert.include(
+          toast.root.textContent, 'Refresh credentials');
+    });
+
+    test('regular toast should dismiss regular toast', () => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+
+      // 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(
+          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(toast.root.textContent, 'second-test');
+    });
+
+    test('regular toast should not dismiss auth toast', done => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      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 chained
+        // promises on server-error handler and flush only flushes one
+        assert.equal(window.fetch.callCount, 2);
+        flush(() => {
+          let toast = toastSpy.lastCall.returnValue;
+          assert.include(
+              toast.root.textContent, 'Credentials expired.');
+          assert.include(
+              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(
+                toast.root.textContent, 'Credentials expired.');
+            done();
+          });
+        });
+      });
+    });
+
+    test('show alert', () => {
+      const alertObj = {message: 'foo'};
+      sinon.stub(element, '_showAlert');
+      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]);
+      assert.isNotOk(element._showAlert.lastCall.args[2]);
+    });
+
+    test('checks stale credentials on visibility change', () => {
+      const refreshStub = sinon.stub(element,
+          '_checkSignedIn');
+      sinon.stub(Date, 'now').returns(999999);
+      element._lastCredentialCheck = 0;
+      element._handleVisibilityChange();
+
+      // Since there is no known account, it should not test credentials.
+      assert.isFalse(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 0);
+
+      element.knownAccountId = 123;
+      element._handleVisibilityChange();
+
+      // Should test credentials, since there is a known account.
+      assert.isTrue(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 999999);
+    });
+
+    test('refreshes with same credentials', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
+      sinon.stub(element.$.restAPI, 'getAccount')
+          .returns(accountPromise);
+      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(element,
+          '_handleCredentialRefreshed');
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element.knownAccountId = 1234;
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isFalse(requestCheckStub.called);
+        assert.isTrue(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+
+    test('_showAlert hides existing alerts', () => {
+      element._alertElement = element._createToastAlert();
+      const hideStub = sinon.stub(element, '_hideAlert');
+      element._showAlert();
+      assert.isTrue(hideStub.calledOnce);
+    });
+
+    test('show-error', () => {
+      const openStub = sinon.stub(element.$.errorOverlay, 'open');
+      const closeStub = sinon.stub(element.$.errorOverlay, 'close');
+      const reportStub = sinon.stub(
+          element.reporting,
+          'reportErrorDialog'
+      );
+
+      const message = 'test message';
+      element.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message},
+            composed: true, bubbles: true,
+          }));
+      flush();
+
+      assert.isTrue(openStub.called);
+      assert.isTrue(reportStub.called);
+      assert.equal(element.$.errorDialog.text, message);
+
+      element.$.errorDialog.dispatchEvent(
+          new CustomEvent('dismiss', {
+            composed: true, bubbles: true,
+          }));
+      flush();
+
+      assert.isTrue(closeStub.called);
+    });
+
+    test('reloads when refreshed credentials differ', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
+      sinon.stub(element.$.restAPI, 'getAccount')
+          .returns(accountPromise);
+      const requestCheckStub = sinon.stub(
+          element,
+          '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(element,
+          '_handleCredentialRefreshed');
+      const reloadStub = sinon.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', () => {
+    let toastSpy;
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+      element = basicFixture.instantiate();
+      toastSpy = sinon.spy(element, '_createToastAlert');
+    });
+
+    teardown(() => {
+      toastSpy.getCalls().forEach(call => {
+        call.returnValue.remove();
+      });
+    });
+
+    test('refresh loop continues on credential fail', done => {
+      const requestCheckStub = sinon.stub(
+          element,
+          '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(element,
+          '_handleCredentialRefreshed');
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isTrue(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index 5d7ec27..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
+++ /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.
- */
-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-key-binding-display_html.js';
-
-/** @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);
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
new file mode 100644
index 0000000..796a167
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -0,0 +1,48 @@
+/**
+ * @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.
+ */
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-key-binding-display_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-key-binding-display': GrKeyBindingDisplay;
+  }
+}
+
+@customElement('gr-key-binding-display')
+export class GrKeyBindingDisplay extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array})
+  binding: string[][] = [];
+
+  _computeModifiers(binding: string[][]) {
+    return binding.slice(0, binding.length - 1);
+  }
+
+  _computeKey(binding: string[][]) {
+    return binding[binding.length - 1];
+  }
+}
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
deleted file mode 100644
index 334a40a..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
new file mode 100644
index 0000000..0a75104
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 8ae0f69..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
+++ /dev/null
@@ -1,67 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-<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="/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-key-binding-display></gr-key-binding-display>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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']);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
new file mode 100644
index 0000000..0c25e6e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
@@ -0,0 +1,54 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-key-binding-display.js';
+
+const basicFixture = fixtureFromElement('gr-key-binding-display');
+
+suite('gr-key-binding-display tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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']);
+    });
+  });
+});
+
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
deleted file mode 100644
index beb0f7e..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const {ShortcutSection} = KeyboardShortcutBinder;
-
-/**
- * @extends Polymer.Element
- */
-class GrKeyboardShortcutsDialog extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  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,
-
-      _propertyBySection: {
-        type: Object,
-        value() {
-          return {
-            [ShortcutSection.EVERYWHERE]: '_everywhere',
-            [ShortcutSection.NAVIGATION]: '_navigation',
-            [ShortcutSection.DASHBOARD]: '_dashboard',
-            [ShortcutSection.CHANGE_LIST]: '_changeList',
-            [ShortcutSection.ACTIONS]: '_actions',
-            [ShortcutSection.REPLY_DIALOG]: '_replyDialog',
-            [ShortcutSection.FILE_LIST]: '_fileList',
-            [ShortcutSection.DIFFS]: '_diffs',
-          };
-        },
-      },
-    };
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.addKeyboardShortcutDirectoryListener(
-        this._onDirectoryUpdated.bind(this));
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.removeKeyboardShortcutDirectoryListener(
-        this._onDirectoryUpdated.bind(this));
-  }
-
-  _handleCloseTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('close', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _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.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.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.DIFFS)) {
-      right.push({
-        section: ShortcutSection.DIFFS,
-        shortcuts: directory.get(ShortcutSection.DIFFS),
-      });
-    }
-
-    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.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
new file mode 100644
index 0000000..4bd90ea
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -0,0 +1,157 @@
+/**
+ * @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 '../../shared/gr-button/gr-button';
+import '../gr-key-binding-display/gr-key-binding-display';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html';
+import {
+  KeyboardShortcutMixin,
+  ShortcutSection,
+  ShortcutListener,
+  SectionView,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {property, customElement} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-keyboard-shortcuts-dialog': GrKeyboardShortcutsDialog;
+  }
+}
+
+interface SectionShortcut {
+  section: ShortcutSection;
+  shortcuts?: SectionView;
+}
+
+@customElement('gr-keyboard-shortcuts-dialog')
+export class GrKeyboardShortcutsDialog extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the user presses the close button.
+   *
+   * @event close
+   */
+
+  @property({type: Array})
+  _left?: SectionShortcut[];
+
+  @property({type: Array})
+  _right?: SectionShortcut[];
+
+  private keyboardShortcutDirectoryListener: ShortcutListener;
+
+  constructor() {
+    super();
+    this.keyboardShortcutDirectoryListener = (
+      d?: Map<ShortcutSection, SectionView>
+    ) => this._onDirectoryUpdated(d);
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.addKeyboardShortcutDirectoryListener(
+      this.keyboardShortcutDirectoryListener
+    );
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.removeKeyboardShortcutDirectoryListener(
+      this.keyboardShortcutDirectoryListener
+    );
+  }
+
+  _handleCloseTap(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('close', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
+    if (!directory) {
+      return;
+    }
+    const left = [] as SectionShortcut[];
+    const right = [] as SectionShortcut[];
+
+    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.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.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),
+      });
+    }
+
+    this.set('_left', left);
+    this.set('_right', right);
+  }
+}
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
deleted file mode 100644
index 78b576e..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
new file mode 100644
index 0000000..1860f38
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
@@ -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';
+
+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 class="heading-3">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
deleted file mode 100644
index 1b5cd0f..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
+++ /dev/null
@@ -1,181 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-<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="/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-keyboard-shortcuts-dialog></gr-keyboard-shortcuts-dialog>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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);
-    });
-
-    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('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('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('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('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'],
-            },
-          ]);
-    });
-  });
-});
-</script>
-
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js
new file mode 100644
index 0000000..f76041e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js
@@ -0,0 +1,166 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-keyboard-shortcuts-dialog.js';
+import {ShortcutSection} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+
+const basicFixture = fixtureFromElement('gr-keyboard-shortcuts-dialog');
+
+suite('gr-keyboard-shortcuts-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  function update(directory) {
+    element._onDirectoryUpdated(directory);
+    flush();
+  }
+
+  suite('_left and _right contents', () => {
+    test('empty dialog', () => {
+      assert.strictEqual(element._left.length, 0);
+      assert.strictEqual(element._right.length, 0);
+    });
+
+    test('everywhere goes on left', () => {
+      update(new Map([
+        [ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: ShortcutSection.EVERYWHERE,
+              shortcuts: ['everywhere shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._right.length, 0);
+    });
+
+    test('navigation goes on left', () => {
+      update(new Map([
+        [ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: ShortcutSection.NAVIGATION,
+              shortcuts: ['navigation shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._right.length, 0);
+    });
+
+    test('actions go on right', () => {
+      update(new Map([
+        [ShortcutSection.ACTIONS, ['actions shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.ACTIONS,
+              shortcuts: ['actions shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
+
+    test('reply dialog goes on right', () => {
+      update(new Map([
+        [ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.REPLY_DIALOG,
+              shortcuts: ['reply dialog shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
+
+    test('file list goes on right', () => {
+      update(new Map([
+        [ShortcutSection.FILE_LIST, ['file list shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.FILE_LIST,
+              shortcuts: ['file list shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
+
+    test('diffs go on right', () => {
+      update(new Map([
+        [ShortcutSection.DIFFS, ['diffs shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.DIFFS,
+              shortcuts: ['diffs shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
+
+    test('multiple sections on each side', () => {
+      update(new Map([
+        [ShortcutSection.ACTIONS, ['actions shortcuts']],
+        [ShortcutSection.DIFFS, ['diffs shortcuts']],
+        [ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+        [ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: ShortcutSection.EVERYWHERE,
+              shortcuts: ['everywhere shortcuts'],
+            },
+            {
+              section: ShortcutSection.NAVIGATION,
+              shortcuts: ['navigation shortcuts'],
+            },
+          ]);
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.ACTIONS,
+              shortcuts: ['actions shortcuts'],
+            },
+            {
+              section: ShortcutSection.DIFFS,
+              shortcuts: ['diffs shortcuts'],
+            },
+          ]);
+    });
+  });
+});
+
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
deleted file mode 100644
index 44537e1..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ /dev/null
@@ -1,359 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 DEFAULT_LINKS = [{
-  title: 'Changes',
-  links: [
-    {
-      url: '/q/status:open+-is:wip',
-      name: 'Open',
-    },
-    {
-      url: '/q/status:merged',
-      name: 'Merged',
-    },
-    {
-      url: '/q/status:abandoned',
-      name: 'Abandoned',
-    },
-  ],
-}];
-
-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',
-  },
-];
-
-// Set of authentication methods that can provide custom registration page.
-const AUTH_TYPES_WITH_REGISTER_URL = new Set([
-  'LDAP',
-  'LDAP_BIND',
-  'CUSTOM_EXTENSION',
-]);
-
-/**
- * @extends Polymer.Element
- */
-class GrMainHeader extends mixinBehaviors( [
-  AdminNavBehavior,
-  BaseUrlBehavior,
-  DocsUrlBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-main-header'; }
-
-  static get properties() {
-    return {
-      searchQuery: {
-        type: String,
-        notify: true,
-      },
-      loggedIn: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      loading: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-
-      /** @type {?Object} */
-      _account: Object,
-      _adminLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _defaultLinks: {
-        type: Array,
-        value() {
-          return DEFAULT_LINKS;
-        },
-      },
-      _docBaseUrl: {
-        type: String,
-        value: null,
-      },
-      _links: {
-        type: Array,
-        computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
-          '_topMenus, _docBaseUrl)',
-      },
-      loginUrl: {
-        type: String,
-        value: '/login',
-      },
-      _userLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _topMenus: {
-        type: Array,
-        value() { return []; },
-      },
-      _registerText: {
-        type: String,
-        value: 'Sign up',
-      },
-      _registerURL: {
-        type: String,
-        value: null,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_accountLoaded(_account)',
-    ];
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'banner');
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadAccount();
-    this._loadConfig();
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-  }
-
-  reload() {
-    this._loadAccount();
-  }
-
-  _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;
-    }
-
-    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',
-      });
-    }
-    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,
-        });
-      }
-    }
-    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;
-      }
-    }
-  }
-
-  _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.dispatchEvent(new CustomEvent('mobile-search', {
-      composed: true, 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.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
new file mode 100644
index 0000000..ee103b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -0,0 +1,393 @@
+/**
+ * @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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-account-dropdown/gr-account-dropdown';
+import '../gr-smart-search/gr-smart-search';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-main-header_html';
+import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AccountDetailInfo,
+  RequireProperties,
+  ServerInfo,
+  TopMenuEntryInfo,
+  TopMenuItemInfo,
+} from '../../../types/common';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {AuthType} from '../../../constants/constants';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+
+type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
+
+interface MainHeaderLinkGroup {
+  title: string;
+  links: MainHeaderLink[];
+  class?: string;
+}
+
+const DEFAULT_LINKS: MainHeaderLinkGroup[] = [
+  {
+    title: 'Changes',
+    links: [
+      {
+        url: '/q/status:open+-is:wip',
+        name: 'Open',
+      },
+      {
+        url: '/q/status:merged',
+        name: 'Merged',
+      },
+      {
+        url: '/q/status:abandoned',
+        name: 'Abandoned',
+      },
+    ],
+  },
+];
+
+const DOCUMENTATION_LINKS: MainHeaderLink[] = [
+  {
+    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',
+  },
+];
+
+// Set of authentication methods that can provide custom registration page.
+const AUTH_TYPES_WITH_REGISTER_URL: Set<AuthType> = new Set([
+  AuthType.LDAP,
+  AuthType.LDAP_BIND,
+  AuthType.CUSTOM_EXTENSION,
+]);
+
+export interface GrMainHeader {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: JsApiService & Element;
+  };
+}
+
+@customElement('gr-main-header')
+export class GrMainHeader extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, notify: true})
+  searchQuery?: string;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loggedIn?: boolean;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loading?: boolean;
+
+  @property({type: Object})
+  _account?: AccountDetailInfo;
+
+  @property({type: Array})
+  _adminLinks: NavLink[] = [];
+
+  @property({type: String})
+  _docBaseUrl: string | null = null;
+
+  @property({
+    type: Array,
+    computed: '_computeLinks(_userLinks, _adminLinks, _topMenus, _docBaseUrl)',
+  })
+  _links?: MainHeaderLinkGroup[];
+
+  @property({type: String})
+  loginUrl = '/login';
+
+  @property({type: Array})
+  _userLinks: MainHeaderLink[] = [];
+
+  @property({type: Array})
+  _topMenus?: TopMenuEntryInfo[] = [];
+
+  @property({type: String})
+  _registerText = 'Sign up';
+
+  // Empty string means that the register <div> will be hidden.
+  @property({type: String})
+  _registerURL = '';
+
+  @property({type: Boolean})
+  mobileSearchHidden = false;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'banner');
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadAccount();
+    this._loadConfig();
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+  }
+
+  reload() {
+    this._loadAccount();
+  }
+
+  _computeRelativeURL(path: string) {
+    return '//' + window.location.host + getBaseUrl() + path;
+  }
+
+  _computeLinks(
+    userLinks?: TopMenuItemInfo[],
+    adminLinks?: NavLink[],
+    topMenus?: TopMenuEntryInfo[],
+    docBaseUrl?: string | null,
+    // defaultLinks parameter is used in tests only
+    defaultLinks = DEFAULT_LINKS
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      userLinks === undefined ||
+      adminLinks === undefined ||
+      topMenus === undefined ||
+      docBaseUrl === undefined
+    ) {
+      return undefined;
+    }
+
+    const links: MainHeaderLinkGroup[] = 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',
+      });
+    }
+    links.push({
+      title: 'Browse',
+      links: adminLinks.slice(),
+    });
+    const topMenuLinks: {[name: string]: MainHeaderLink[]} = {};
+    links.forEach(link => {
+      topMenuLinks[link.title] = link.links;
+    });
+    for (const m of topMenus) {
+      const items = m.items.map(this._createHeaderLink).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,
+        });
+      }
+    }
+    return links;
+  }
+
+  _getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
+    if (!docBaseUrl) {
+      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;
+
+    return Promise.all([
+      this.$.restAPI.getAccount(),
+      this.$.restAPI.getTopMenus(),
+      getPluginLoader().awaitPluginsLoaded(),
+    ]).then(result => {
+      const account = result[0];
+      this._account = account;
+      this.loggedIn = !!account;
+      this.loading = false;
+      this._topMenus = result[1];
+
+      return getAdminLinks(
+        account,
+        () =>
+          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+            if (!capabilities) {
+              throw new Error('getAccountCapabilities returns undefined');
+            }
+            return capabilities;
+          }),
+        () => this.$.jsAPI.getAdminMenuLinks()
+      ).then(res => {
+        this._adminLinks = res.links;
+      });
+    });
+  }
+
+  _loadConfig() {
+    this.$.restAPI
+      .getConfig()
+      .then(config => {
+        if (!config) {
+          throw new Error('getConfig returned undefined');
+        }
+        this._retrieveRegisterURL(config);
+        return getDocsBaseUrl(config, this.$.restAPI);
+      })
+      .then(docBaseUrl => {
+        this._docBaseUrl = docBaseUrl;
+      });
+  }
+
+  @observe('_account')
+  _accountLoaded(account?: AccountDetailInfo) {
+    if (!account) {
+      return;
+    }
+
+    this.$.restAPI.getPreferences().then(prefs => {
+      this._userLinks =
+        prefs && prefs.my ? prefs.my.map(this._createHeaderLink) : [];
+    });
+  }
+
+  _retrieveRegisterURL(config: ServerInfo) {
+    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;
+      }
+    }
+  }
+
+  _computeRegisterHidden(registerURL: string) {
+    return !registerURL;
+  }
+
+  _createHeaderLink(linkObj: TopMenuItemInfo): MainHeaderLink {
+    // 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.
+    const {target, ...headerLink} = {...linkObj};
+
+    // Normalize all urls to PolyGerrit style.
+    if (headerLink.url.startsWith('#')) {
+      headerLink.url = linkObj.url.slice(1);
+    }
+
+    return headerLink;
+  }
+
+  _generateSettingsLink() {
+    return getBaseUrl() + '/settings/';
+  }
+
+  _onMobileSearchTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('mobile-search', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _computeLinkGroupClass(linkGroup: MainHeaderLinkGroup) {
+    return linkGroup.class ?? '';
+  }
+
+  _computeShowHideAriaLabel(mobileSearchHidden: boolean) {
+    if (mobileSearchHidden) {
+      return 'Show Searchbar';
+    } else {
+      return 'Hide Searchbar';
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-main-header': 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
deleted file mode 100644
index 19e833c..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
+++ /dev/null
@@ -1,233 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
new file mode 100644
index 0000000..5778fb8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
@@ -0,0 +1,243 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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"
+        label="Search for changes"
+        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"
+          role="button"
+          aria-label="[[_computeShowHideAriaLabel(mobileSearchHidden)]]"
+        ></iron-icon>
+        <div
+          class="registerDiv"
+          hidden="[[_computeRegisterHidden(_registerURL)]]"
+        >
+          <a class="registerButton" href$="[[_registerURL]]">
+            [[_registerText]]
+          </a>
+        </div>
+        <a class="loginButton" href$="[[loginUrl]]">Sign in</a>
+        <a
+          class="settingsButton"
+          href$="[[_generateSettingsLink()]]"
+          title="Settings"
+          aria-label="Settings"
+          role="button"
+        >
+          <iron-icon icon="gr-icons:settings"></iron-icon>
+        </a>
+        <template is="dom-if" if="[[_account]]">
+          <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
+        </template>
+      </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
deleted file mode 100644
index 336d873..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ /dev/null
@@ -1,410 +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">
-<meta charset="utf-8">
-<title>gr-main-header</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-main-header></gr-main-header>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
new file mode 100644
index 0000000..3ab40e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -0,0 +1,521 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {isHidden, query} from '../../../test/test-utils';
+import './gr-main-header';
+import {GrMainHeader} from './gr-main-header';
+import {
+  createAccountDetailWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {NavLink} from '../../../utils/admin-nav-util';
+import {ServerInfo, TopMenuItemInfo} from '../../../types/common';
+import {AuthType} from '../../../constants/constants';
+
+const basicFixture = fixtureFromElement('gr-main-header');
+
+suite('gr-main-header tests', () => {
+  let element: GrMainHeader;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() {
+        return Promise.resolve(createServerInfo());
+      },
+      probePath(_) {
+        return Promise.resolve(false);
+      },
+    });
+    stub('gr-main-header', {
+      _loadAccount() {
+        return Promise.resolve();
+      },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('link visibility', () => {
+    element.loading = true;
+    assert.isTrue(isHidden(query(element, '.accountContainer')));
+
+    element.loading = false;
+    element.loggedIn = false;
+    assert.isFalse(isHidden(query(element, '.accountContainer')));
+    assert.isFalse(isHidden(query(element, '.loginButton')));
+    assert.isFalse(isHidden(query(element, '.registerButton')));
+    assert.isTrue(isHidden(query(element, '.registerDiv')));
+
+    element._account = createAccountDetailWithId(1);
+    flush();
+    assert.isTrue(isHidden(query(element, 'gr-account-dropdown')));
+    assert.isTrue(isHidden(query(element, '.settingsButton')));
+
+    element.loggedIn = true;
+    assert.isTrue(isHidden(query(element, '.loginButton')));
+    assert.isTrue(isHidden(query(element, '.registerButton')));
+    assert.isFalse(isHidden(query(element, 'gr-account-dropdown')));
+    assert.isFalse(isHidden(query(element, '.settingsButton')));
+  });
+
+  test('fix my menu item', () => {
+    assert.deepEqual(
+      [
+        {url: 'https://awesometown.com/#hashyhash', name: '', target: ''},
+        {url: 'url', name: '', target: '_blank'},
+      ].map(element._createHeaderLink),
+      [
+        {url: 'https://awesometown.com/#hashyhash', name: ''},
+        {url: 'url', name: ''},
+      ]
+    );
+  });
+
+  test('user links', () => {
+    const defaultLinks = [
+      {
+        title: 'Faves',
+        links: [
+          {
+            name: 'Pinterest',
+            url: 'https://pinterest.com',
+          },
+        ],
+      },
+    ];
+    const userLinks: TopMenuItemInfo[] = [
+      {
+        name: 'Facebook',
+        url: 'https://facebook.com',
+        target: '',
+      },
+    ];
+    const adminLinks: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+
+    // When no admin links are passed, it should use the default.
+    assert.deepEqual(
+      element._computeLinks(
+        /* userLinks= */ [],
+        adminLinks,
+        /* topMenus= */ [],
+        /* docBaseUrl= */ '',
+        defaultLinks
+      ),
+      defaultLinks.concat({
+        title: 'Browse',
+        links: adminLinks,
+      })
+    );
+    assert.deepEqual(
+      element._computeLinks(
+        userLinks,
+        adminLinks,
+        /* topMenus= */ [],
+        /* docBaseUrl= */ '',
+        defaultLinks
+      ),
+      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', []), []);
+
+    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: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+    const topMenus = [
+      {
+        name: 'Plugins',
+        items: [
+          {
+            name: 'Manage',
+            target: '_blank',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(
+      element._computeLinks(
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          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: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+    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(
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          title: 'Browse',
+          links: adminLinks,
+        },
+        {
+          title: 'Projects',
+          links: [
+            {
+              name: 'Project List',
+              url: '/plugins/myplugin/index.html',
+            },
+          ],
+        },
+      ]
+    );
+  });
+
+  test('merge top menus', () => {
+    const adminLinks: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+    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(
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          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(
+        /* userLinks= */ [],
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ '',
+        defaultLinks
+      ),
+      [
+        {
+          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',
+        target: '',
+      },
+    ];
+    const topMenus = [
+      {
+        name: 'Your',
+        items: [
+          {
+            name: 'Manage',
+            target: '_blank',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(
+      element._computeLinks(
+        userLinks,
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          title: 'Your',
+          links: [
+            {
+              name: 'Facebook',
+              url: 'https://facebook.com',
+              target: '',
+            },
+            {
+              name: 'Manage',
+              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+            },
+          ],
+        },
+        {
+          title: 'Browse',
+          links: [],
+        },
+      ]
+    );
+  });
+
+  test('merge top menus in admin links', () => {
+    const adminLinks: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+    const topMenus = [
+      {
+        name: 'Browse',
+        items: [
+          {
+            name: 'Manage',
+            target: '_blank',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(
+      element._computeLinks(
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          title: 'Browse',
+          links: [
+            adminLinks[0],
+            {
+              name: 'Manage',
+              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+            },
+          ],
+        },
+      ]
+    );
+  });
+
+  test('register URL', () => {
+    assert.isTrue(isHidden(query(element, '.registerDiv')));
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      auth: {
+        auth_type: AuthType.LDAP,
+        register_url: 'https//gerrit.example.com/register',
+        editable_account_fields: [],
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, config.auth.register_url);
+    assert.equal(element._registerText, 'Sign up');
+    assert.isFalse(isHidden(query(element, '.registerDiv')));
+
+    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);
+    assert.isFalse(isHidden(query(element, '.registerDiv')));
+  });
+
+  test('register URL ignored for wrong auth type', () => {
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      auth: {
+        auth_type: AuthType.OPENID,
+        register_url: 'https//gerrit.example.com/register',
+        editable_account_fields: [],
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, '');
+    assert.equal(element._registerText, 'Sign up');
+    assert.isTrue(isHidden(query(element, '.registerDiv')));
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
deleted file mode 100644
index 2b87548..0000000
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
+++ /dev/null
@@ -1,742 +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.
- */
-
-// 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.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
new file mode 100644
index 0000000..8470611
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -0,0 +1,987 @@
+/**
+ * @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 {
+  BranchName,
+  ChangeInfo,
+  PatchSetNum,
+  RepoName,
+  TopicName,
+  GroupId,
+  DashboardId,
+  NumericChangeId,
+  EditPatchSetNum,
+  ChangeConfigInfo,
+  CommitId,
+  Hashtag,
+  UrlEncodedCommentId,
+  CommentLinks,
+  ParentPatchSetNum,
+  ServerInfo,
+} from '../../../types/common';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+// 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 uninitializedNavigate: NavigateCallback = () => {
+  uninitialized();
+  return '';
+};
+
+const uninitializedGenerateUrl: GenerateUrlCallback = () => {
+  uninitialized();
+  return '';
+};
+
+const uninitializedGenerateWebLinks: GenerateWebLinksCallback = () => {
+  uninitialized();
+  return [];
+};
+
+const uninitializedMapCommentLinks: MapCommentLinksCallback = () => {
+  uninitialized();
+  return {};
+};
+
+const USER_PLACEHOLDER_PATTERN = /\${user}/g;
+
+export interface DashboardSection {
+  name: string;
+  query: string;
+  suffixForDashboard?: string;
+  attentionSetOnly?: boolean;
+  selfOnly?: boolean;
+  hideIfEmpty?: boolean;
+  assigneeOnly?: boolean;
+  isOutgoing?: boolean;
+  results?: ChangeInfo[];
+}
+
+export interface UserDashboardConfig {
+  change?: ChangeConfigInfo;
+}
+
+export interface UserDashboard {
+  title?: string;
+  sections: DashboardSection[];
+}
+
+// NOTE: These queries are tested in Java. Any changes made to definitions
+// here require corresponding changes to:
+// java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+const HAS_DRAFTS: DashboardSection = {
+  // 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',
+};
+export const YOUR_TURN: DashboardSection = {
+  // Changes where the user is in the attention set.
+  name: 'Your Turn',
+  query: 'attention:${user}',
+  hideIfEmpty: false,
+  suffixForDashboard: 'limit:25',
+  attentionSetOnly: true,
+};
+const ASSIGNED: DashboardSection = {
+  // 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',
+  assigneeOnly: true,
+};
+const WIP: DashboardSection = {
+  // 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',
+};
+const OUTGOING: DashboardSection = {
+  // 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',
+};
+const INCOMING: DashboardSection = {
+  // 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',
+};
+const CCED: DashboardSection = {
+  // 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',
+};
+export const CLOSED: DashboardSection = {
+  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',
+};
+const DEFAULT_SECTIONS: DashboardSection[] = [
+  HAS_DRAFTS,
+  YOUR_TURN,
+  ASSIGNED,
+  WIP,
+  OUTGOING,
+  INCOMING,
+  CCED,
+  CLOSED,
+];
+
+export interface GenerateUrlSearchViewParameters {
+  view: GerritView.SEARCH;
+  query?: string;
+  offset?: number;
+  project?: RepoName;
+  branch?: BranchName;
+  topic?: TopicName;
+  // TODO(TS): Define more precise type (enum?)
+  statuses?: string[];
+  hashtag?: string;
+  host?: string;
+  owner?: string;
+}
+
+export interface GenerateUrlChangeViewParameters {
+  view: GerritView.CHANGE;
+  // TODO(TS): NumericChangeId - not sure about it, may be it can be removeds
+  changeNum: NumericChangeId;
+  project: RepoName;
+  patchNum?: PatchSetNum;
+  basePatchNum?: PatchSetNum;
+  edit?: boolean;
+  host?: string;
+  messageHash?: string;
+  queryMap?: Map<string, string> | URLSearchParams;
+
+  // TODO(TS): querystring isn't set anywhere, try to remove
+  querystring?: string;
+}
+
+export interface GenerateUrlRepoViewParameters {
+  view: GerritView.REPO;
+  repoName: RepoName;
+  detail?: RepoDetailView;
+}
+
+export interface GenerateUrlDashboardViewParameters {
+  view: GerritView.DASHBOARD;
+  user?: string;
+  repo?: RepoName;
+  dashboard?: DashboardId;
+
+  // TODO(TS): properties bellow aren't set anywhere, try to remove
+  project?: RepoName;
+  sections?: DashboardSection[];
+  title?: string;
+}
+
+export interface GenerateUrlGroupViewParameters {
+  view: GerritView.GROUP;
+  groupId: GroupId;
+  detail?: GroupDetailView;
+}
+
+export interface GenerateUrlEditViewParameters {
+  view: GerritView.EDIT;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  path: string;
+  patchNum: PatchSetNum;
+  lineNum?: number | string;
+}
+
+export interface GenerateUrlRootViewParameters {
+  view: GerritView.ROOT;
+}
+
+export interface GenerateUrlSettingsViewParameters {
+  view: GerritView.SETTINGS;
+}
+
+export interface GenerateUrlDiffViewParameters {
+  view: GerritView.DIFF;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  path?: string;
+  patchNum?: PatchSetNum | null;
+  basePatchNum?: PatchSetNum | null;
+  lineNum?: number | string;
+  leftSide?: boolean;
+  commentId?: UrlEncodedCommentId;
+  // TODO(TS): remove - property is set but never used
+  commentLink?: boolean;
+}
+
+export type GenerateUrlParameters =
+  | GenerateUrlSearchViewParameters
+  | GenerateUrlChangeViewParameters
+  | GenerateUrlRepoViewParameters
+  | GenerateUrlDashboardViewParameters
+  | GenerateUrlGroupViewParameters
+  | GenerateUrlEditViewParameters
+  | GenerateUrlRootViewParameters
+  | GenerateUrlSettingsViewParameters
+  | GenerateUrlDiffViewParameters;
+
+export function isGenerateUrlChangeViewParameters(
+  x: GenerateUrlParameters
+): x is GenerateUrlChangeViewParameters {
+  return x.view === GerritView.CHANGE;
+}
+
+export function isGenerateUrlEditViewParameters(
+  x: GenerateUrlParameters
+): x is GenerateUrlEditViewParameters {
+  return x.view === GerritView.EDIT;
+}
+
+export function isGenerateUrlDiffViewParameters(
+  x: GenerateUrlParameters
+): x is GenerateUrlDiffViewParameters {
+  return x.view === GerritView.DIFF;
+}
+
+export interface GenerateWebLinksOptions {
+  weblinks?: GeneratedWebLink[];
+  config?: ServerInfo;
+}
+
+export interface GenerateWebLinksPatchsetParameters {
+  type: WeblinkType.PATCHSET;
+  repo: RepoName;
+  commit?: CommitId;
+  options?: GenerateWebLinksOptions;
+}
+export interface GenerateWebLinksFileParameters {
+  type: WeblinkType.FILE;
+  repo: RepoName;
+  commit: CommitId;
+  file: string;
+  options?: GenerateWebLinksOptions;
+}
+export interface GenerateWebLinksChangeParameters {
+  type: WeblinkType.CHANGE;
+  repo: RepoName;
+  commit: CommitId;
+  options?: GenerateWebLinksOptions;
+}
+
+export type GenerateWebLinksParameters =
+  | GenerateWebLinksPatchsetParameters
+  | GenerateWebLinksFileParameters
+  | GenerateWebLinksChangeParameters;
+
+export type NavigateCallback = (target: string, redirect?: boolean) => void;
+export type GenerateUrlCallback = (params: GenerateUrlParameters) => string;
+export type GenerateWebLinksCallback = (
+  params: GenerateWebLinksParameters
+) => GeneratedWebLink[] | GeneratedWebLink;
+
+export type MapCommentLinksCallback = (patterns: CommentLinks) => CommentLinks;
+
+export interface WebLink {
+  name?: string;
+  label: string;
+  url: string;
+}
+
+export interface GeneratedWebLink {
+  name?: string;
+  label?: string;
+  url?: string;
+}
+
+export enum GerritView {
+  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',
+}
+
+export enum GroupDetailView {
+  MEMBERS = 'members',
+  LOG = 'log',
+}
+
+export enum RepoDetailView {
+  ACCESS = 'access',
+  BRANCHES = 'branches',
+  COMMANDS = 'commands',
+  DASHBOARDS = 'dashboards',
+  TAGS = 'tags',
+}
+
+export enum WeblinkType {
+  CHANGE = 'change',
+  FILE = 'file',
+  PATCHSET = 'patchset',
+}
+
+// TODO(dmfilippov) Convert to class, extract consts, give better name and
+// expose as a service from appContext
+export const GerritNav = {
+  View: GerritView,
+
+  GroupDetailView,
+
+  RepoDetailView,
+
+  WeblinkType,
+
+  _navigate: uninitializedNavigate,
+
+  _generateUrl: uninitializedGenerateUrl,
+
+  _generateWeblinks: uninitializedGenerateWebLinks,
+
+  mapCommentlinks: uninitializedMapCommentLinks,
+
+  _checkPatchRange(patchNum?: PatchSetNum, basePatchNum?: PatchSetNum) {
+    if (basePatchNum && !patchNum) {
+      throw new Error('Cannot use base patch number without patch number.');
+    }
+  },
+
+  /**
+   * Setup router implementation.
+   *
+   * @param navigate the router-abstracted equivalent of
+   *     `window.location.href = ...` or window.location.replace(...). The
+   *     string is a new location and boolean defines is it redirect or not
+   *     (true means redirect, i.e. equivalent of window.location.replace).
+   * @param generateUrl generates a URL given
+   *     navigation parameters, detailed in the file header.
+   * @param 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 mapCommentlinks provides an escape
+   *     hatch to modify the commentlinks object, e.g. if it contains any
+   *     relative URLs.
+   */
+  setup(
+    navigate: NavigateCallback,
+    generateUrl: GenerateUrlCallback,
+    generateWeblinks: GenerateWebLinksCallback,
+    mapCommentlinks: MapCommentLinksCallback
+  ) {
+    this._navigate = navigate;
+    this._generateUrl = generateUrl;
+    this._generateWeblinks = generateWeblinks;
+    this.mapCommentlinks = mapCommentlinks;
+  },
+
+  destroy() {
+    this._navigate = uninitializedNavigate;
+    this._generateUrl = uninitializedGenerateUrl;
+    this._generateWeblinks = uninitializedGenerateWebLinks;
+    this.mapCommentlinks = uninitializedMapCommentLinks;
+  },
+
+  /**
+   * Generate a URL for the given route parameters.
+   */
+  _getUrlFor(params: GenerateUrlParameters) {
+    return this._generateUrl(params);
+  },
+
+  getUrlForSearchQuery(query: string, offset?: number) {
+    return this._getUrlFor({
+      view: GerritView.SEARCH,
+      query,
+      offset,
+    });
+  },
+
+  /**
+   * @param openOnly When true, only search open changes in the project.
+   * @param host The host in which to search.
+   */
+  getUrlForProjectChanges(
+    project: RepoName,
+    openOnly?: boolean,
+    host?: string
+  ) {
+    return this._getUrlFor({
+      view: GerritView.SEARCH,
+      project,
+      statuses: openOnly ? ['open'] : [],
+      host,
+    });
+  },
+
+  /**
+   * @param status The status to search.
+   * @param host The host in which to search.
+   */
+  getUrlForBranch(
+    branch: BranchName,
+    project: RepoName,
+    status?: string,
+    host?: string
+  ) {
+    return this._getUrlFor({
+      view: GerritView.SEARCH,
+      branch,
+      project,
+      statuses: status ? [status] : undefined,
+      host,
+    });
+  },
+
+  /**
+   * @param topic The name of the topic.
+   * @param host The host in which to search.
+   */
+  getUrlForTopic(topic: TopicName, host?: string) {
+    return this._getUrlFor({
+      view: GerritView.SEARCH,
+      topic,
+      statuses: ['open', 'merged'],
+      host,
+    });
+  },
+
+  /**
+   * @param hashtag The name of the hashtag.
+   */
+  getUrlForHashtag(hashtag: Hashtag) {
+    return this._getUrlFor({
+      view: GerritView.SEARCH,
+      hashtag,
+      statuses: ['open', 'merged'],
+    });
+  },
+
+  /**
+   * Navigate to a search for changes with the given status.
+   */
+  navigateToStatusSearch(status: string) {
+    this._navigate(
+      this._getUrlFor({
+        view: GerritView.SEARCH,
+        statuses: [status],
+      })
+    );
+  },
+
+  /**
+   * Navigate to a search query
+   */
+  navigateToSearchQuery(query: string, offset?: number) {
+    return this._navigate(this.getUrlForSearchQuery(query, offset));
+  },
+
+  /**
+   * Navigate to the user's dashboard
+   */
+  navigateToUserDashboard() {
+    return this._navigate(this.getUrlForUserDashboard('self'));
+  },
+
+  /**
+   * @param basePatchNum The string 'PARENT' can be used for none.
+   */
+  getUrlForChange(
+    change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
+    patchNum?: PatchSetNum,
+    basePatchNum?: PatchSetNum,
+    isEdit?: boolean,
+    messageHash?: string
+  ) {
+    if (basePatchNum === ParentPatchSetNum) {
+      basePatchNum = undefined;
+    }
+
+    this._checkPatchRange(patchNum, basePatchNum);
+    return this._getUrlFor({
+      view: GerritView.CHANGE,
+      changeNum: change._number,
+      project: change.project,
+      patchNum,
+      basePatchNum,
+      edit: isEdit,
+      host: change.internalHost || undefined,
+      messageHash,
+    });
+  },
+
+  getUrlForChangeById(
+    changeNum: NumericChangeId,
+    project: RepoName,
+    patchNum?: PatchSetNum
+  ) {
+    return this._getUrlFor({
+      view: GerritView.CHANGE,
+      changeNum,
+      project,
+      patchNum,
+    });
+  },
+
+  /**
+   * @param basePatchNum The string 'PARENT' can be used for none.
+   * @param redirect redirect to a change - if true, the current
+   *     location (i.e. page which makes redirect) is not added to a history.
+   *     I.e. back/forward buttons skip current location
+   *
+   */
+  navigateToChange(
+    change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
+    patchNum?: PatchSetNum,
+    basePatchNum?: PatchSetNum,
+    isEdit?: boolean,
+    redirect?: boolean
+  ) {
+    this._navigate(
+      this.getUrlForChange(change, patchNum, basePatchNum, isEdit),
+      redirect
+    );
+  },
+
+  /**
+   * @param basePatchNum The string 'PARENT' can be used for none.
+   */
+  getUrlForDiff(
+    change: ChangeInfo | ParsedChangeInfo,
+    filePath: string,
+    patchNum?: PatchSetNum,
+    basePatchNum?: PatchSetNum,
+    lineNum?: number
+  ) {
+    return this.getUrlForDiffById(
+      change._number,
+      change.project,
+      filePath,
+      patchNum,
+      basePatchNum,
+      lineNum
+    );
+  },
+
+  getUrlForComment(
+    changeNum: NumericChangeId,
+    project: RepoName,
+    commentId: UrlEncodedCommentId
+  ) {
+    return this._getUrlFor({
+      view: GerritView.DIFF,
+      changeNum,
+      project,
+      commentId,
+    });
+  },
+
+  /**
+   * @param basePatchNum The string 'PARENT' can be used for none.
+   */
+  getUrlForDiffById(
+    changeNum: NumericChangeId,
+    project: RepoName,
+    filePath: string,
+    patchNum?: PatchSetNum,
+    basePatchNum?: PatchSetNum,
+    lineNum?: number,
+    leftSide?: boolean
+  ) {
+    if (basePatchNum === ParentPatchSetNum) {
+      basePatchNum = undefined;
+    }
+
+    this._checkPatchRange(patchNum, basePatchNum);
+    return this._getUrlFor({
+      view: GerritView.DIFF,
+      changeNum,
+      project,
+      path: filePath,
+      patchNum,
+      basePatchNum,
+      lineNum,
+      leftSide,
+    });
+  },
+
+  getEditUrlForDiff(
+    change: ChangeInfo | ParsedChangeInfo,
+    filePath: string,
+    patchNum?: PatchSetNum,
+    lineNum?: number
+  ) {
+    return this.getEditUrlForDiffById(
+      change._number,
+      change.project,
+      filePath,
+      patchNum,
+      lineNum
+    );
+  },
+
+  /**
+   * @param patchNum The patchNum the file content should be based on, or
+   *   ${EditPatchSetNum} if left undefined.
+   * @param lineNum The line number to pass to the inline editor.
+   */
+  getEditUrlForDiffById(
+    changeNum: NumericChangeId,
+    project: RepoName,
+    filePath: string,
+    patchNum?: PatchSetNum,
+    lineNum?: number
+  ) {
+    return this._getUrlFor({
+      view: GerritView.EDIT,
+      changeNum,
+      project,
+      path: filePath,
+      patchNum: patchNum || EditPatchSetNum,
+      lineNum,
+    });
+  },
+
+  /**
+   * @param basePatchNum The string 'PARENT' can be used for none.
+   */
+  navigateToDiff(
+    change: ChangeInfo | ParsedChangeInfo,
+    filePath: string,
+    patchNum?: PatchSetNum,
+    basePatchNum?: PatchSetNum,
+    lineNum?: number
+  ) {
+    this._navigate(
+      this.getUrlForDiff(change, filePath, patchNum, basePatchNum, lineNum)
+    );
+  },
+
+  /**
+   * @param owner The name of the owner.
+   */
+  getUrlForOwner(owner: string) {
+    return this._getUrlFor({
+      view: GerritView.SEARCH,
+      owner,
+    });
+  },
+
+  /**
+   * @param user The name of the user.
+   */
+  getUrlForUserDashboard(user: string) {
+    return this._getUrlFor({
+      view: GerritView.DASHBOARD,
+      user,
+    });
+  },
+
+  getUrlForRoot() {
+    return this._getUrlFor({
+      view: GerritView.ROOT,
+    });
+  },
+
+  /**
+   * @param repo The name of the repo.
+   * @param dashboard The ID of the dashboard, in the form of '<ref>:<path>'.
+   */
+  getUrlForRepoDashboard(repo: RepoName, dashboard: DashboardId) {
+    return this._getUrlFor({
+      view: GerritView.DASHBOARD,
+      repo,
+      dashboard,
+    });
+  },
+
+  /**
+   * Navigate to an arbitrary relative URL.
+   */
+  navigateToRelativeUrl(relativeUrl: string) {
+    if (!relativeUrl.startsWith('/')) {
+      throw new Error('navigateToRelativeUrl with non-relative URL');
+    }
+    this._navigate(relativeUrl);
+  },
+
+  getUrlForRepo(repoName: RepoName) {
+    return this._getUrlFor({
+      view: GerritView.REPO,
+      repoName,
+    });
+  },
+
+  /**
+   * Navigate to a repo settings page.
+   */
+  navigateToRepo(repoName: RepoName) {
+    this._navigate(this.getUrlForRepo(repoName));
+  },
+
+  getUrlForRepoTags(repoName: RepoName) {
+    return this._getUrlFor({
+      view: GerritView.REPO,
+      repoName,
+      detail: RepoDetailView.TAGS,
+    });
+  },
+
+  getUrlForRepoBranches(repoName: RepoName) {
+    return this._getUrlFor({
+      view: GerritView.REPO,
+      repoName,
+      detail: GerritNav.RepoDetailView.BRANCHES,
+    });
+  },
+
+  getUrlForRepoAccess(repoName: RepoName) {
+    return this._getUrlFor({
+      view: GerritView.REPO,
+      repoName,
+      detail: GerritNav.RepoDetailView.ACCESS,
+    });
+  },
+
+  getUrlForRepoCommands(repoName: RepoName) {
+    return this._getUrlFor({
+      view: GerritView.REPO,
+      repoName,
+      detail: GerritNav.RepoDetailView.COMMANDS,
+    });
+  },
+
+  getUrlForRepoDashboards(repoName: RepoName) {
+    return this._getUrlFor({
+      view: GerritView.REPO,
+      repoName,
+      detail: GerritNav.RepoDetailView.DASHBOARDS,
+    });
+  },
+
+  getUrlForGroup(groupId: GroupId) {
+    return this._getUrlFor({
+      view: GerritView.GROUP,
+      groupId,
+    });
+  },
+
+  getUrlForGroupLog(groupId: GroupId) {
+    return this._getUrlFor({
+      view: GerritView.GROUP,
+      groupId,
+      detail: GerritNav.GroupDetailView.LOG,
+    });
+  },
+
+  getUrlForGroupMembers(groupId: GroupId) {
+    return this._getUrlFor({
+      view: GerritView.GROUP,
+      groupId,
+      detail: GroupDetailView.MEMBERS,
+    });
+  },
+
+  getUrlForSettings() {
+    return this._getUrlFor({view: GerritView.SETTINGS});
+  },
+
+  getFileWebLinks(
+    repo: RepoName,
+    commit: CommitId,
+    file: string,
+    options?: GenerateWebLinksOptions
+  ): GeneratedWebLink[] {
+    const params: GenerateWebLinksFileParameters = {
+      type: WeblinkType.FILE,
+      repo,
+      commit,
+      file,
+    };
+    if (options) {
+      params.options = options;
+    }
+    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
+  },
+
+  getPatchSetWeblink(
+    repo: RepoName,
+    commit?: CommitId,
+    options?: GenerateWebLinksOptions
+  ): GeneratedWebLink {
+    const params: GenerateWebLinksPatchsetParameters = {
+      type: WeblinkType.PATCHSET,
+      repo,
+      commit,
+    };
+    if (options) {
+      params.options = options;
+    }
+    const result = this._generateWeblinks(params);
+    if (Array.isArray(result)) {
+      // TODO(TS): Unclear what to do with empty array.
+      // Either write a comment why result can't be empty or change the return
+      // type or add a check.
+      return result.pop()!;
+    } else {
+      return result;
+    }
+  },
+
+  getChangeWeblinks(
+    repo: RepoName,
+    commit: CommitId,
+    options?: GenerateWebLinksOptions
+  ): GeneratedWebLink[] {
+    const params: GenerateWebLinksChangeParameters = {
+      type: WeblinkType.CHANGE,
+      repo,
+      commit,
+    };
+    if (options) {
+      params.options = options;
+    }
+    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
+  },
+
+  getUserDashboard(
+    user = 'self',
+    sections = DEFAULT_SECTIONS,
+    title = '',
+    config: UserDashboardConfig = {}
+  ): UserDashboard {
+    const attentionEnabled =
+      config.change && !!config.change.enable_attention_set;
+    const assigneeEnabled = config.change && !!config.change.enable_assignee;
+    sections = sections
+      .filter(section => attentionEnabled || !section.attentionSetOnly)
+      .filter(section => assigneeEnabled || !section.assigneeOnly)
+      .filter(section => user === 'self' || !section.selfOnly)
+      .map(section => {
+        return {
+          ...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
deleted file mode 100644
index 8fc4c75..0000000
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
+++ /dev/null
@@ -1,88 +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">
-<meta charset="utf-8">
-<title>gr-navigation</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 {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',
-              },
-            ],
-          });
-    });
-
-    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-navigation/gr-navigation_test.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js
new file mode 100644
index 0000000..93a1e9e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js
@@ -0,0 +1,78 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.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',
+              },
+            ],
+          });
+    });
+
+    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',
+              },
+            ],
+          });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
deleted file mode 100644
index c8b4ff5..0000000
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ /dev/null
@@ -1,603 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {appContext} from '../../../services/app-context.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',
-};
-
-// Plugin-related reporting constants.
-const PLUGINS = {
-  TYPE: 'lifecycle',
-  // Reported events - alphabetize below.
-  INSTALLED: 'Plugins installed',
-};
-
-// 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',
-};
-
-const ERROR = {
-  TYPE: 'error',
-  CATEGORY: 'exception',
-};
-
-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 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 DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
-
-let pending = [];
-let slowRpcList = [];
-const SLOW_RPC_THRESHOLD = 500;
-
-// 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);
-  }
-  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();
-  }
-  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 = {
-      error: e.reason,
-    };
-    GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
-  });
-};
-catchErrors();
-
-// PerformanceObserver interface is a browser API.
-if (window.PerformanceObserver) {
-  const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
-  // Safari doesn't support longtask yet
-  if (supportedEntryTypes.includes('longtask')) {
-    const catchLongJsTasks = new PerformanceObserver(list => {
-      for (const task of list.getEntries()) {
-        // We are interested in longtask longer than 200 ms (default is 50 ms)
-        if (task.duration > 200) {
-          GrReporting.prototype.reporter(TIMING.TYPE,
-              TIMING.CATEGORY_UI_LATENCY, `Task ${task.name}`,
-              Math.round(task.duration), {}, false);
-        }
-      }
-    });
-    catchLongJsTasks.observe({entryTypes: ['longtask']});
-  }
-}
-
-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
deleted file mode 100644
index e140d6c..0000000
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ /dev/null
@@ -1,437 +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">
-<meta charset="utf-8">
-<title>gr-reporting</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-reporting></gr-reporting>
-  </template>
-</test-fixture>
-
-<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;
-
-  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(() => {
-      element.reporter.restore();
-      sandbox.stub(element, '_reportEvent');
-    });
-
-    test('pluginsLoaded reports time', () => {
-      sandbox.stub(element, 'now').returns(42);
-      element.pluginsLoaded();
-      assert.isTrue(element._reportEvent.calledWithMatch(
-          {
-            type: 'timing-report',
-            category: 'UI Latency',
-            name: 'PluginsLoaded',
-            value: 42,
-          }
-      ));
-    });
-
-    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('caches reports if plugins are not loaded', () => {
-      element.timeEnd('foo');
-      assert.isFalse(element._reportEvent.called);
-    });
-
-    test('reports if plugins are loaded', () => {
-      element.pluginsLoaded();
-      assert.isTrue(element._reportEvent.called);
-    });
-
-    test('reports if metrics plugin xyz is loaded', () => {
-      element.pluginLoaded('metrics-xyz');
-      assert.isTrue(element._reportEvent.called);
-    });
-
-    test('reports cached events preserving order', () => {
-      element.time('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'}
-      ));
-      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.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
deleted file mode 100644
index e861f2e..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ /dev/null
@@ -1,1573 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const RoutePattern = {
-  ROOT: '/',
-
-  DASHBOARD: /^\/dashboard\/(.+)$/,
-  CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
-  PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
-  LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
-
-  AGREEMENTS: /^\/settings\/agreements\/?/,
-  NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
-  REGISTER: /^\/register(\/.*)?$/,
-
-  // Pattern for login and logout URLs intended to be passed-through. May
-  // include a return URL.
-  LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
-
-  // Pattern for a catchall route when no other pattern is matched.
-  DEFAULT: /.*/,
-
-  // Matches /admin/groups/[uuid-]<group>
-  GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
-
-  // Redirects /groups/self to /settings/#Groups for GWT compatibility
-  GROUP_SELF: /^\/groups\/self/,
-
-  // 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/<group>,audit-log
-  GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
-
-  // Matches /admin/groups/[uuid-]<group>,members
-  GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
-
-  // 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_PROJECT: /^\/admin\/create-project\/?$/,
-
-  // Matches /admin/create-project
-  LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
-
-  PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
-
-  // Matches /admin/repos/<repo>
-  REPO: /^\/admin\/repos\/([^,]+)$/,
-
-  // Matches /admin/repos/<repo>,commands.
-  REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
-
-  // Matches /admin/repos/<repos>,access.
-  REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
-
-  // Matches /admin/repos/<repos>,access.
-  REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
-
-  // 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>,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/<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',
-
-  PLUGINS: /^\/plugins\/(.+)$/,
-
-  PLUGIN_LIST: /^\/admin\/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',
-
-  QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
-
-  /**
-   * 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(\/)?(.+)?/,
-};
-
-/**
- * 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,
-      },
-      _isRedirecting: Boolean,
-      // This variable is to differentiate between internal navigation (false)
-      // and for first navigation in app after loaded from server (true).
-      _isInitialLoad: {
-        type: Boolean,
-        value: true,
-      },
-    };
-  }
-
-  start() {
-    if (!this._app) { return; }
-    this._startRouter();
-  }
-
-  _setParams(params) {
-    this._appElement().params = params;
-  }
-
-  _appElement() {
-    // In Polymer2 you have to reach through the shadow root of the app
-    // 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);
-  }
-
-  /**
-   * @param {!Object} params
-   * @return {string}
-   */
-  _generateUrl(params) {
-    const base = this.getBaseUrl();
-    let url = '';
-    const Views = GerritNav.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');
-    }
-
-    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 {
-        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(); });
-  }
-
-  /**  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 {
-        queryMap = new Map(this._parseQueryString(ctx.querystring));
-      }
-    }
-    ctx.queryMap = queryMap;
-    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);
-      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();
-      }
-      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.dispatchEvent(new CustomEvent('location-change', {
-          detail: {
-            hash: window.location.hash,
-            pathname: window.location.pathname,
-          },
-          composed: true, bubbles: true,
-        }));
-      }, 1);
-      next();
-    });
-
-    this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
-
-    this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
-
-    this._mapRoute(RoutePattern.CUSTOM_DASHBOARD,
-        '_handleCustomDashboardRoute');
-
-    this._mapRoute(RoutePattern.PROJECT_DASHBOARD,
-        '_handleProjectDashboardRoute');
-
-    this._mapRoute(RoutePattern.LEGACY_PROJECT_DASHBOARD,
-        '_handleLegacyProjectDashboardRoute');
-
-    this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
-
-    this._mapRoute(RoutePattern.GROUP_AUDIT_LOG, '_handleGroupAuditLogRoute',
-        true);
-
-    this._mapRoute(RoutePattern.GROUP_MEMBERS, '_handleGroupMembersRoute',
-        true);
-
-    this._mapRoute(RoutePattern.GROUP_LIST_OFFSET,
-        '_handleGroupListOffsetRoute', true);
-
-    this._mapRoute(RoutePattern.GROUP_LIST_FILTER_OFFSET,
-        '_handleGroupListFilterOffsetRoute', true);
-
-    this._mapRoute(RoutePattern.GROUP_LIST_FILTER,
-        '_handleGroupListFilterRoute', true);
-
-    this._mapRoute(RoutePattern.GROUP_SELF, '_handleGroupSelfRedirectRoute',
-        true);
-
-    this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
-
-    this._mapRoute(RoutePattern.PROJECT_OLD,
-        '_handleProjectsOldRoute');
-
-    this._mapRoute(RoutePattern.REPO_COMMANDS,
-        '_handleRepoCommandsRoute', true);
-
-    this._mapRoute(RoutePattern.REPO_ACCESS,
-        '_handleRepoAccessRoute');
-
-    this._mapRoute(RoutePattern.REPO_DASHBOARDS,
-        '_handleRepoDashboardsRoute');
-
-    this._mapRoute(RoutePattern.BRANCH_LIST_OFFSET,
-        '_handleBranchListOffsetRoute');
-
-    this._mapRoute(RoutePattern.BRANCH_LIST_FILTER_OFFSET,
-        '_handleBranchListFilterOffsetRoute');
-
-    this._mapRoute(RoutePattern.BRANCH_LIST_FILTER,
-        '_handleBranchListFilterRoute');
-
-    this._mapRoute(RoutePattern.TAG_LIST_OFFSET,
-        '_handleTagListOffsetRoute');
-
-    this._mapRoute(RoutePattern.TAG_LIST_FILTER_OFFSET,
-        '_handleTagListFilterOffsetRoute');
-
-    this._mapRoute(RoutePattern.TAG_LIST_FILTER,
-        '_handleTagListFilterRoute');
-
-    this._mapRoute(RoutePattern.LEGACY_CREATE_GROUP,
-        '_handleCreateGroupRoute', true);
-
-    this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
-        '_handleCreateProjectRoute', true);
-
-    this._mapRoute(RoutePattern.REPO_LIST_OFFSET,
-        '_handleRepoListOffsetRoute');
-
-    this._mapRoute(RoutePattern.REPO_LIST_FILTER_OFFSET,
-        '_handleRepoListFilterOffsetRoute');
-
-    this._mapRoute(RoutePattern.REPO_LIST_FILTER,
-        '_handleRepoListFilterRoute');
-
-    this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
-
-    this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
-
-    this._mapRoute(RoutePattern.PLUGIN_LIST_OFFSET,
-        '_handlePluginListOffsetRoute', true);
-
-    this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
-        '_handlePluginListFilterOffsetRoute', true);
-
-    this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER,
-        '_handlePluginListFilterRoute', true);
-
-    this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
-
-    this._mapRoute(RoutePattern.QUERY_LEGACY_SUFFIX,
-        '_handleQueryLegacySuffixRoute');
-
-    this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
-
-    this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
-
-    this._mapRoute(RoutePattern.CHANGE_NUMBER_LEGACY,
-        '_handleChangeNumberLegacyRoute');
-
-    this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
-
-    this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
-
-    this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
-
-    this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
-
-    this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
-
-    this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
-
-    this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
-
-    this._mapRoute(RoutePattern.NEW_AGREEMENTS, '_handleNewAgreementsRoute',
-        true);
-
-    this._mapRoute(RoutePattern.SETTINGS_LEGACY,
-        '_handleSettingsLegacyRoute', true);
-
-    this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
-
-    this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
-
-    this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
-
-    this._mapRoute(RoutePattern.IMPROPERLY_ENCODED_PLUS,
-        '_handleImproperlyEncodedPlusRoute');
-
-    this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
-
-    this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH_FILTER,
-        '_handleDocumentationSearchRoute');
-
-    // 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');
-
-    // Note: this route should appear last so it only catches URLs unmatched
-    // by other patterns.
-    this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
-
-    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;
-    }
-    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;
-    }
-    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/owner:' + encodeURIComponent(data.params[0]));
-        }
-      } else {
-        this._setParams({
-          view: GerritNav.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.
-      this._setParams({
-        view: GerritNav.View.DASHBOARD,
-        user: 'self',
-        sections,
-        title,
-      });
-      return Promise.resolve();
-    }
-
-    // Redirect /dashboard/ -> /dashboard/self.
-    this._redirect('/dashboard/self');
-    return Promise.resolve();
-  }
-
-  _handleProjectDashboardRoute(data) {
-    const project = data.params[0];
-    this._setParams({
-      view: GerritNav.View.DASHBOARD,
-      project,
-      dashboard: decodeURIComponent(data.params[1]),
-    });
-    this.$.reporting.setRepoName(project);
-  }
-
-  _handleLegacyProjectDashboardRoute(data) {
-    this._redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
-  }
-
-  _handleGroupInfoRoute(data) {
-    this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
-  }
-
-  _handleGroupSelfRedirectRoute(data) {
-    this._redirect('/settings/#Groups');
-  }
-
-  _handleGroupRoute(data) {
-    this._setParams({
-      view: GerritNav.View.GROUP,
-      groupId: data.params[0],
-    });
-  }
-
-  _handleGroupAuditLogRoute(data) {
-    this._setParams({
-      view: GerritNav.View.GROUP,
-      detail: GerritNav.GroupDetailView.LOG,
-      groupId: data.params[0],
-    });
-  }
-
-  _handleGroupMembersRoute(data) {
-    this._setParams({
-      view: GerritNav.View.GROUP,
-      detail: GerritNav.GroupDetailView.MEMBERS,
-      groupId: data.params[0],
-    });
-  }
-
-  _handleGroupListOffsetRoute(data) {
-    this._setParams({
-      view: GerritNav.View.ADMIN,
-      adminView: 'gr-admin-group-list',
-      offset: data.params[1] || 0,
-      filter: null,
-      openCreateModal: data.hash === 'create',
-    });
-  }
-
-  _handleGroupListFilterOffsetRoute(data) {
-    this._setParams({
-      view: GerritNav.View.ADMIN,
-      adminView: 'gr-admin-group-list',
-      offset: data.params.offset,
-      filter: data.params.filter,
-    });
-  }
-
-  _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}`);
-  }
-
-  _handleRepoCommandsRoute(data) {
-    const repo = data.params[0];
-    this._setParams({
-      view: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.COMMANDS,
-      repo,
-    });
-    this.$.reporting.setRepoName(repo);
-  }
-
-  _handleRepoAccessRoute(data) {
-    const repo = data.params[0];
-    this._setParams({
-      view: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.ACCESS,
-      repo,
-    });
-    this.$.reporting.setRepoName(repo);
-  }
-
-  _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: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.BRANCHES,
-      repo: data.params[0],
-      offset: data.params[2] || 0,
-      filter: null,
-    });
-  }
-
-  _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: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.BRANCHES,
-      repo: data.params.repo,
-      filter: data.params.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: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.TAGS,
-      repo: data.params.repo,
-      offset: data.params.offset,
-      filter: data.params.filter,
-    });
-  }
-
-  _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: GerritNav.View.ADMIN,
-      adminView: 'gr-repo-list',
-      offset: data.params[1] || 0,
-      filter: null,
-      openCreateModal: data.hash === 'create',
-    });
-  }
-
-  _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: 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');
-  }
-
-  _handleCreateGroupRoute(data) {
-    // Redirects the legacy route to the new route, which displays the group
-    // list with a hash 'create'.
-    this._redirect('/admin/groups#create');
-  }
-
-  _handleRepoRoute(data) {
-    const repo = data.params[0];
-    this._setParams({
-      view: GerritNav.View.REPO,
-      repo,
-    });
-    this.$.reporting.setRepoName(repo);
-  }
-
-  _handlePluginListOffsetRoute(data) {
-    this._setParams({
-      view: GerritNav.View.ADMIN,
-      adminView: 'gr-plugin-list',
-      offset: data.params[1] || 0,
-      filter: null,
-    });
-  }
-
-  _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: GerritNav.View.ADMIN,
-      adminView: 'gr-plugin-list',
-      filter: data.params.filter || null,
-    });
-  }
-
-  _handlePluginListRoute(data) {
-    this._setParams({
-      view: GerritNav.View.ADMIN,
-      adminView: 'gr-plugin-list',
-    });
-  }
-
-  _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, ''));
-  }
-
-  _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: GerritNav.View.CHANGE,
-      queryMap: ctx.queryMap,
-    };
-
-    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: GerritNav.View.DIFF,
-    };
-
-    const address = this._parseLineAddress(ctx.hash);
-    if (address) {
-      params.leftSide = address.leftSide;
-      params.lineNum = address.lineNum;
-    }
-    this.$.reporting.setRepoName(params.project);
-    this._redirectOrNavigate(params);
-  }
-
-  _handleChangeLegacyRoute(ctx) {
-    // Parameter order is based on the regex group number matched.
-    const params = {
-      changeNum: ctx.params[0],
-      basePatchNum: ctx.params[3],
-      patchNum: ctx.params[5],
-      view: GerritNav.View.CHANGE,
-      querystring: ctx.querystring,
-    };
-
-    this._normalizeLegacyRouteParams(params);
-  }
-
-  _handleLegacyLinenum(ctx) {
-    this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
-  }
-
-  _handleDiffLegacyRoute(ctx) {
-    // Parameter order is based on the regex group number matched.
-    const params = {
-      changeNum: ctx.params[0],
-      basePatchNum: ctx.params[2],
-      patchNum: ctx.params[4],
-      path: ctx.params[5],
-      view: GerritNav.View.DIFF,
-    };
-
-    const address = this._parseLineAddress(ctx.hash);
-    if (address) {
-      params.leftSide = address.leftSide;
-      params.lineNum = address.lineNum;
-    }
-
-    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);
-  }
-
-  _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);
-  }
-
-  /**
-   * 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');
-  }
-
-  _handleNewAgreementsRoute(data) {
-    data.params.view = GerritNav.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,
-    });
-  }
-
-  _handleSettingsRoute(data) {
-    this._setParams({view: GerritNav.View.SETTINGS});
-  }
-
-  _handleRegisterRoute(ctx) {
-    this._setParams({justRegistered: true});
-    let path = ctx.params[0] || '/';
-
-    // Prevent redirect looping.
-    if (path.startsWith('/register')) { 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();
-  }
-
-  /**
-   * 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();
-    }
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
new file mode 100644
index 0000000..21bf900
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -0,0 +1,1781 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {
+  page,
+  PageContext,
+  PageNextCallback,
+} from '../../../utils/page-wrapper-utils';
+import {htmlTemplate} from './gr-router_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {
+  DashboardSection,
+  GenerateUrlChangeViewParameters,
+  GenerateUrlDashboardViewParameters,
+  GenerateUrlDiffViewParameters,
+  GenerateUrlEditViewParameters,
+  GenerateUrlGroupViewParameters,
+  GenerateUrlParameters,
+  GenerateUrlRepoViewParameters,
+  GenerateUrlSearchViewParameters,
+  GenerateWebLinksChangeParameters,
+  GenerateWebLinksFileParameters,
+  GenerateWebLinksParameters,
+  GenerateWebLinksPatchsetParameters,
+  GerritView,
+  isGenerateUrlDiffViewParameters,
+  RepoDetailView,
+  WeblinkType,
+  GroupDetailView,
+  GerritNav,
+  GeneratedWebLink,
+} from '../gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {
+  patchNumEquals,
+  convertToPatchSetNum,
+} from '../../../utils/patch-set-util';
+import {customElement, property} from '@polymer/decorators';
+import {assertNever} from '../../../utils/common-util';
+import {
+  DashboardId,
+  GroupId,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  ServerInfo,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AppElement,
+  AppElementParams,
+  AppElementAgreementParam,
+} from '../../gr-app-types';
+import {LocationChangeEventDetail} from '../../../types/events';
+
+const RoutePattern = {
+  ROOT: '/',
+
+  DASHBOARD: /^\/dashboard\/(.+)$/,
+  CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
+  PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+  LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
+
+  AGREEMENTS: /^\/settings\/agreements\/?/,
+  NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
+  REGISTER: /^\/register(\/.*)?$/,
+
+  // Pattern for login and logout URLs intended to be passed-through. May
+  // include a return URL.
+  LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+
+  // Pattern for a catchall route when no other pattern is matched.
+  DEFAULT: /.*/,
+
+  // Matches /admin/groups/[uuid-]<group>
+  GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
+
+  // Redirects /groups/self to /settings/#Groups for GWT compatibility
+  GROUP_SELF: /^\/groups\/self/,
+
+  // 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/<group>,audit-log
+  GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
+
+  // Matches /admin/groups/[uuid-]<group>,members
+  GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
+
+  // 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_PROJECT: /^\/admin\/create-project\/?$/,
+
+  // Matches /admin/create-project
+  LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
+
+  PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
+
+  // Matches /admin/repos/<repo>
+  REPO: /^\/admin\/repos\/([^,]+)$/,
+
+  // Matches /admin/repos/<repo>,commands.
+  REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
+
+  // Matches /admin/repos/<repos>,access.
+  REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
+
+  // Matches /admin/repos/<repos>,access.
+  REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
+
+  // 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>,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/<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',
+
+  PLUGINS: /^\/plugins\/(.+)$/,
+
+  PLUGIN_LIST: /^\/admin\/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',
+
+  QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
+
+  /**
+   * Support vestigial params from GWT UI.
+   *
+   * @see Issue 7673.
+   * @type {!RegExp}
+   */
+  QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
+
+  CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
+
+  // 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>/comment/<commentId>/
+  // Navigates to the diff view
+  // This route is needed to resolve to patchNum vs latestPatchNum used in the
+  // links generated in the emails.
+  COMMENT: /^\/c\/(.+)\/\+\/(\d+)\/comment\/(\w+)\/?$/,
+
+  // 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(\/)?(.+)?/,
+};
+
+export const _testOnly_RoutePattern = RoutePattern;
+
+/**
+ * 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.info('No gr-app found (running tests)');
+}
+
+// Setup listeners outside of the router component initialization.
+(function () {
+  window.addEventListener('WebComponentsReady', () => {
+    appContext.reportingService.timeEnd('WebComponentsReady');
+  });
+})();
+
+export interface GrRouter {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+export interface PageContextWithQueryMap extends PageContext {
+  queryMap: Map<string, string> | URLSearchParams;
+}
+
+type QueryStringItem = [string, string]; // [key, value]
+
+type GenerateUrlLegacyChangeViewParameters = Omit<
+  GenerateUrlChangeViewParameters,
+  'project'
+>;
+type GenerateUrlLegacyDiffViewParameters = Omit<
+  GenerateUrlDiffViewParameters,
+  'project'
+>;
+
+interface PatchRangeParams {
+  patchNum?: PatchSetNum | null;
+  basePatchNum?: PatchSetNum | null;
+}
+
+@customElement('gr-router')
+export class GrRouter extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  readonly _app = app;
+
+  @property({type: Boolean})
+  _isRedirecting?: boolean;
+
+  // This variable is to differentiate between internal navigation (false)
+  // and for first navigation in app after loaded from server (true).
+  @property({type: Boolean})
+  _isInitialLoad = true;
+
+  private readonly reporting = appContext.reportingService;
+
+  constructor() {
+    super();
+  }
+
+  start() {
+    if (!this._app) {
+      return;
+    }
+    this._startRouter();
+  }
+
+  _setParams(params: AppElementParams | GenerateUrlParameters) {
+    this._appElement().params = params;
+  }
+
+  _appElement(): 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.
+
+    // It is expected that application has a GrAppElement(id=='app-element')
+    // at the document level or inside the shadow root of the GrApp (id='app')
+    // element.
+    return (document.getElementById('app-element') ||
+      document
+        .getElementById('app')!
+        .shadowRoot!.getElementById('app-element')!) as AppElement;
+  }
+
+  _redirect(url: string) {
+    this._isRedirecting = true;
+    page.redirect(url);
+  }
+
+  _generateUrl(params: GenerateUrlParameters) {
+    const base = getBaseUrl();
+    let url = '';
+
+    if (params.view === GerritView.SEARCH) {
+      url = this._generateSearchUrl(params);
+    } else if (params.view === GerritView.CHANGE) {
+      url = this._generateChangeUrl(params);
+    } else if (params.view === GerritView.DASHBOARD) {
+      url = this._generateDashboardUrl(params);
+    } else if (
+      params.view === GerritView.DIFF ||
+      params.view === GerritView.EDIT
+    ) {
+      url = this._generateDiffOrEditUrl(params);
+    } else if (params.view === GerritView.GROUP) {
+      url = this._generateGroupUrl(params);
+    } else if (params.view === GerritView.REPO) {
+      url = this._generateRepoUrl(params);
+    } else if (params.view === GerritView.ROOT) {
+      url = '/';
+    } else if (params.view === GerritView.SETTINGS) {
+      url = this._generateSettingsUrl();
+    } else {
+      assertNever(params, "Can't generate");
+    }
+
+    return base + url;
+  }
+
+  _generateWeblinks(
+    params: GenerateWebLinksParameters
+  ): GeneratedWebLink[] | GeneratedWebLink {
+    switch (params.type) {
+      case WeblinkType.FILE:
+        return this._getFileWebLinks(params);
+      case WeblinkType.CHANGE:
+        return this._getChangeWeblinks(params);
+      case WeblinkType.PATCHSET:
+        return this._getPatchSetWeblink(params);
+      default:
+        console.warn(`Unsupported weblink ${(params as any).type}!`);
+        // TODO(TS): use assertNever(params.type)
+        return [];
+    }
+  }
+
+  _getPatchSetWeblink(
+    params: GenerateWebLinksPatchsetParameters
+  ): GeneratedWebLink {
+    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: GeneratedWebLink[]) {
+    // This is an ordered allowed list 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?: GeneratedWebLink[], config?: ServerInfo) {
+    if (!weblinks) {
+      return null;
+    }
+    let weblink;
+    // Use primary weblink if configured and exists.
+    if (config?.gerrit?.primary_weblink_name) {
+      const primaryWeblinkName = config.gerrit.primary_weblink_name;
+      weblink = weblinks.find(weblink => weblink.name === primaryWeblinkName);
+    }
+    if (!weblink) {
+      weblink = this._firstCodeBrowserWeblink(weblinks);
+    }
+    if (!weblink) {
+      return null;
+    }
+    return weblink;
+  }
+
+  _getChangeWeblinks(
+    params: GenerateWebLinksChangeParameters
+  ): GeneratedWebLink[] {
+    const weblinks = params.options?.weblinks;
+    const config = params.options?.config;
+    if (!weblinks || !weblinks.length) return [];
+    const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
+    return weblinks.filter(
+      weblink =>
+        !commitWeblink ||
+        !commitWeblink.name ||
+        weblink.name !== commitWeblink.name
+    );
+  }
+
+  _getFileWebLinks(params: GenerateWebLinksFileParameters): GeneratedWebLink[] {
+    return params.options?.weblinks || [];
+  }
+
+  _generateSearchUrl(params: GenerateUrlSearchViewParameters) {
+    let offsetExpr = '';
+    if (params.offset && params.offset > 0) {
+      offsetExpr = `,${params.offset}`;
+    }
+
+    if (params.query) {
+      return '/q/' + encodeURL(params.query, true) + offsetExpr;
+    }
+
+    const operators: string[] = [];
+    if (params.owner) {
+      operators.push('owner:' + encodeURL(params.owner, false));
+    }
+    if (params.project) {
+      operators.push('project:' + encodeURL(params.project, false));
+    }
+    if (params.branch) {
+      operators.push('branch:' + encodeURL(params.branch, false));
+    }
+    if (params.topic) {
+      operators.push('topic:"' + encodeURL(params.topic, false) + '"');
+    }
+    if (params.hashtag) {
+      operators.push(
+        'hashtag:"' + encodeURL(params.hashtag.toLowerCase(), false) + '"'
+      );
+    }
+    if (params.statuses) {
+      if (params.statuses.length === 1) {
+        operators.push('status:' + encodeURL(params.statuses[0], false));
+      } else if (params.statuses.length > 1) {
+        operators.push(
+          '(' +
+            params.statuses
+              .map(s => `status:${encodeURL(s, false)}`)
+              .join(' OR ') +
+            ')'
+        );
+      }
+    }
+
+    return '/q/' + operators.join('+') + offsetExpr;
+  }
+
+  _generateChangeUrl(params: GenerateUrlChangeViewParameters) {
+    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 = encodeURL(params.project, true);
+      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+    } else {
+      return `/c/${params.changeNum}${suffix}`;
+    }
+  }
+
+  _generateDashboardUrl(params: GenerateUrlDashboardViewParameters) {
+    const repoName = params.repo || params.project || undefined;
+    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 = encodeURL(repoName, true);
+      return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
+    } else {
+      // User dashboard.
+      return `/dashboard/${params.user || 'self'}`;
+    }
+  }
+
+  _sectionsToEncodedParams(sections: DashboardSection[], repoName?: 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 = repoName
+        ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
+        : section.query;
+      return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
+    });
+  }
+
+  _generateDiffOrEditUrl(
+    params: GenerateUrlDiffViewParameters | GenerateUrlEditViewParameters
+  ) {
+    let range = this._getPatchRangeExpression(params);
+    if (range.length) {
+      range = '/' + range;
+    }
+
+    let suffix = `${range}/${encodeURL(params.path || '', true)}`;
+
+    if (params.view === GerritView.EDIT) {
+      suffix += ',edit';
+    }
+
+    if (params.lineNum) {
+      suffix += '#';
+      if (isGenerateUrlDiffViewParameters(params) && params.leftSide) {
+        suffix += 'b';
+      }
+      suffix += params.lineNum;
+    }
+
+    if (isGenerateUrlDiffViewParameters(params) && params.commentId) {
+      suffix = `/comment/${params.commentId}` + suffix;
+    }
+
+    if (params.project) {
+      const encodedProject = encodeURL(params.project, true);
+      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+    } else {
+      return `/c/${params.changeNum}${suffix}`;
+    }
+  }
+
+  _generateGroupUrl(params: GenerateUrlGroupViewParameters) {
+    let url = `/admin/groups/${encodeURL(`${params.groupId}`, true)}`;
+    if (params.detail === GroupDetailView.MEMBERS) {
+      url += ',members';
+    } else if (params.detail === GroupDetailView.LOG) {
+      url += ',audit-log';
+    }
+    return url;
+  }
+
+  _generateRepoUrl(params: GenerateUrlRepoViewParameters) {
+    let url = `/admin/repos/${encodeURL(`${params.repoName}`, true)}`;
+    if (params.detail === RepoDetailView.ACCESS) {
+      url += ',access';
+    } else if (params.detail === RepoDetailView.BRANCHES) {
+      url += ',branches';
+    } else if (params.detail === RepoDetailView.TAGS) {
+      url += ',tags';
+    } else if (params.detail === RepoDetailView.COMMANDS) {
+      url += ',commands';
+    } else if (params.detail === RepoDetailView.DASHBOARDS) {
+      url += ',dashboards';
+    }
+    return url;
+  }
+
+  _generateSettingsUrl() {
+    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.
+   */
+  _getPatchRangeExpression(params: PatchRangeParams) {
+    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.
+   */
+  _normalizeLegacyRouteParams(
+    params: Readonly<
+      | GenerateUrlLegacyChangeViewParameters
+      | GenerateUrlLegacyDiffViewParameters
+    >
+  ) {
+    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;
+        }
+        const updatedParams:
+          | GenerateUrlChangeViewParameters
+          | GenerateUrlDiffViewParameters = {...params, project};
+        this._normalizePatchRangeParams(updatedParams);
+        this._redirect(this._generateUrl(updatedParams));
+      });
+  }
+
+  /**
+   * Normalizes the params object, and determines if the URL needs to be
+   * modified to fit the proper schema.
+   *
+   */
+  _normalizePatchRangeParams(params: PatchRangeParams) {
+    if (params.basePatchNum === null || params.basePatchNum === undefined) {
+      return false;
+    }
+    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 (
+      params.patchNum &&
+      patchNumEquals(params.basePatchNum, params.patchNum)
+    ) {
+      needsRedirect = true;
+      params.basePatchNum = null;
+    } else if (!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.
+   */
+  _redirectToLogin(returnUrl: string) {
+    const basePath = 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.
+   *
+   * @return Everything after the first '#' ("a#b#c" -> "b#c").
+   */
+  _getHashFromCanonicalPath(canonicalPath: string) {
+    return canonicalPath.split('#').slice(1).join('#');
+  }
+
+  _parseLineAddress(hash: string) {
+    const match = hash.match(LINE_ADDRESS_PATTERN);
+    if (!match) {
+      return null;
+    }
+    return {
+      leftSide: !!match[1],
+      lineNum: Number(match[2]),
+    };
+  }
+
+  /**
+   * 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.
+   *
+   * @return A promise yielding the original route data
+   * (if it resolves).
+   */
+  _redirectIfNotLoggedIn(data: PageContext) {
+    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(_: PageContext, next: PageNextCallback) {
+    this.$.restAPI.getLoggedIn().then(() => {
+      next();
+    });
+  }
+
+  /**  Page.js middleware that try parse the querystring into queryMap. */
+  _queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
+    let queryMap: Map<string, string> | URLSearchParams = new Map();
+    if (ctx.querystring) {
+      // https://caniuse.com/#search=URLSearchParams
+      if (window.URLSearchParams) {
+        queryMap = new URLSearchParams(ctx.querystring);
+      } else {
+        queryMap = new Map(this._parseQueryString(ctx.querystring));
+      }
+    }
+    (ctx as PageContextWithQueryMap).queryMap = queryMap;
+    next();
+  }
+
+  /**
+   * Map a route to a method on the router.
+   *
+   * @param pattern The page.js pattern for the route.
+   * @param 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 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: string | RegExp,
+    handlerName: keyof GrRouter,
+    authRedirect?: boolean
+  ) {
+    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 = authRedirect
+          ? this._redirectIfNotLoggedIn(data)
+          : Promise.resolve();
+        promise.then(() => {
+          this[handlerName](data as PageContextWithQueryMap);
+        });
+      }
+    );
+  }
+
+  _startRouter() {
+    const base = getBaseUrl();
+    if (base) {
+      page.base(base);
+    }
+
+    GerritNav.setup(
+      (url, redirect?) => {
+        if (redirect) {
+          page.redirect(url);
+        } else {
+          page.show(url);
+        }
+      },
+      params => this._generateUrl(params),
+      params => this._generateWeblinks(params),
+      x => x
+    );
+
+    page.exit('*', (_, 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(() => {
+        const detail: LocationChangeEventDetail = {
+          hash: window.location.hash,
+          pathname: window.location.pathname,
+        };
+        this.dispatchEvent(
+          new CustomEvent('location-change', {
+            detail,
+            composed: true,
+            bubbles: true,
+          })
+        );
+      }, 1);
+      next();
+    });
+
+    this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
+
+    this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
+
+    this._mapRoute(
+      RoutePattern.CUSTOM_DASHBOARD,
+      '_handleCustomDashboardRoute'
+    );
+
+    this._mapRoute(
+      RoutePattern.PROJECT_DASHBOARD,
+      '_handleProjectDashboardRoute'
+    );
+
+    this._mapRoute(
+      RoutePattern.LEGACY_PROJECT_DASHBOARD,
+      '_handleLegacyProjectDashboardRoute'
+    );
+
+    this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
+
+    this._mapRoute(
+      RoutePattern.GROUP_AUDIT_LOG,
+      '_handleGroupAuditLogRoute',
+      true
+    );
+
+    this._mapRoute(
+      RoutePattern.GROUP_MEMBERS,
+      '_handleGroupMembersRoute',
+      true
+    );
+
+    this._mapRoute(
+      RoutePattern.GROUP_LIST_OFFSET,
+      '_handleGroupListOffsetRoute',
+      true
+    );
+
+    this._mapRoute(
+      RoutePattern.GROUP_LIST_FILTER_OFFSET,
+      '_handleGroupListFilterOffsetRoute',
+      true
+    );
+
+    this._mapRoute(
+      RoutePattern.GROUP_LIST_FILTER,
+      '_handleGroupListFilterRoute',
+      true
+    );
+
+    this._mapRoute(
+      RoutePattern.GROUP_SELF,
+      '_handleGroupSelfRedirectRoute',
+      true
+    );
+
+    this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
+
+    this._mapRoute(RoutePattern.PROJECT_OLD, '_handleProjectsOldRoute');
+
+    this._mapRoute(
+      RoutePattern.REPO_COMMANDS,
+      '_handleRepoCommandsRoute',
+      true
+    );
+
+    this._mapRoute(RoutePattern.REPO_ACCESS, '_handleRepoAccessRoute');
+
+    this._mapRoute(RoutePattern.REPO_DASHBOARDS, '_handleRepoDashboardsRoute');
+
+    this._mapRoute(
+      RoutePattern.BRANCH_LIST_OFFSET,
+      '_handleBranchListOffsetRoute'
+    );
+
+    this._mapRoute(
+      RoutePattern.BRANCH_LIST_FILTER_OFFSET,
+      '_handleBranchListFilterOffsetRoute'
+    );
+
+    this._mapRoute(
+      RoutePattern.BRANCH_LIST_FILTER,
+      '_handleBranchListFilterRoute'
+    );
+
+    this._mapRoute(RoutePattern.TAG_LIST_OFFSET, '_handleTagListOffsetRoute');
+
+    this._mapRoute(
+      RoutePattern.TAG_LIST_FILTER_OFFSET,
+      '_handleTagListFilterOffsetRoute'
+    );
+
+    this._mapRoute(RoutePattern.TAG_LIST_FILTER, '_handleTagListFilterRoute');
+
+    this._mapRoute(
+      RoutePattern.LEGACY_CREATE_GROUP,
+      '_handleCreateGroupRoute',
+      true
+    );
+
+    this._mapRoute(
+      RoutePattern.LEGACY_CREATE_PROJECT,
+      '_handleCreateProjectRoute',
+      true
+    );
+
+    this._mapRoute(RoutePattern.REPO_LIST_OFFSET, '_handleRepoListOffsetRoute');
+
+    this._mapRoute(
+      RoutePattern.REPO_LIST_FILTER_OFFSET,
+      '_handleRepoListFilterOffsetRoute'
+    );
+
+    this._mapRoute(RoutePattern.REPO_LIST_FILTER, '_handleRepoListFilterRoute');
+
+    this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
+
+    this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
+
+    this._mapRoute(
+      RoutePattern.PLUGIN_LIST_OFFSET,
+      '_handlePluginListOffsetRoute',
+      true
+    );
+
+    this._mapRoute(
+      RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
+      '_handlePluginListFilterOffsetRoute',
+      true
+    );
+
+    this._mapRoute(
+      RoutePattern.PLUGIN_LIST_FILTER,
+      '_handlePluginListFilterRoute',
+      true
+    );
+
+    this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
+
+    this._mapRoute(
+      RoutePattern.QUERY_LEGACY_SUFFIX,
+      '_handleQueryLegacySuffixRoute'
+    );
+
+    this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
+
+    this._mapRoute(RoutePattern.CHANGE_ID_QUERY, '_handleChangeIdQueryRoute');
+
+    this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
+
+    this._mapRoute(
+      RoutePattern.CHANGE_NUMBER_LEGACY,
+      '_handleChangeNumberLegacyRoute'
+    );
+
+    this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
+
+    this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
+
+    this._mapRoute(RoutePattern.COMMENT, '_handleCommentRoute');
+
+    this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
+
+    this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
+
+    this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
+
+    this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
+
+    this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
+
+    this._mapRoute(
+      RoutePattern.NEW_AGREEMENTS,
+      '_handleNewAgreementsRoute',
+      true
+    );
+
+    this._mapRoute(
+      RoutePattern.SETTINGS_LEGACY,
+      '_handleSettingsLegacyRoute',
+      true
+    );
+
+    this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
+
+    this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
+
+    this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
+
+    this._mapRoute(
+      RoutePattern.IMPROPERLY_ENCODED_PLUS,
+      '_handleImproperlyEncodedPlusRoute'
+    );
+
+    this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
+
+    this._mapRoute(
+      RoutePattern.DOCUMENTATION_SEARCH_FILTER,
+      '_handleDocumentationSearchRoute'
+    );
+
+    // 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'
+    );
+
+    // Note: this route should appear last so it only catches URLs unmatched
+    // by other patterns.
+    this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
+
+    page.start();
+  }
+
+  /**
+   * @return if handling the route involves asynchrony, then a
+   * promise is returned. Otherwise, synchronous handling returns null.
+   */
+  _handleRootRoute(data: PageContextWithQueryMap) {
+    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;
+      }
+      if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
+        // Path decodes all '+' to ' ' -- this breaks project-based URLs.
+        // See Issue 6888.
+        hash = hash.replace('/ /', '/+/');
+      }
+      const base = 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 qs The application/x-www-form-urlencoded string.
+   * @return The decoded string.
+   */
+  _decodeQueryString(qs: string) {
+    return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
+  }
+
+  /**
+   * Parse a query string (e.g. window.location.search) into an array of
+   * name/value pairs.
+   *
+   * @param qs The application/x-www-form-urlencoded query string.
+   * @return An array of name/value pairs, where each
+   * element is a 2-element array.
+   */
+  _parseQueryString(qs: string): Array<QueryStringItem> {
+    qs = qs.replace(QUESTION_PATTERN, '');
+    if (!qs) {
+      return [];
+    }
+    const params: Array<[string, string]> = [];
+    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.
+   */
+  _handleDashboardRoute(data: PageContextWithQueryMap) {
+    // 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: GerritView.DASHBOARD,
+          user: data.params[0],
+        });
+      }
+    });
+  }
+
+  /**
+   * Handle custom dashboard routes.
+   *
+   * @param qs Optional query string associated with the route.
+   * If not given, window.location.search is used. (Used by tests).
+   */
+  _handleCustomDashboardRoute(
+    _: PageContextWithQueryMap,
+    qs: string = 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: string | null = 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: GerritView.DASHBOARD,
+        user: 'self',
+        sections,
+        title,
+      });
+      return Promise.resolve();
+    }
+
+    // Redirect /dashboard/ -> /dashboard/self.
+    this._redirect('/dashboard/self');
+    return Promise.resolve();
+  }
+
+  _handleProjectDashboardRoute(data: PageContextWithQueryMap) {
+    const project = data.params[0] as RepoName;
+    this._setParams({
+      view: GerritView.DASHBOARD,
+      project,
+      dashboard: decodeURIComponent(data.params[1]) as DashboardId,
+    });
+    this.reporting.setRepoName(project);
+  }
+
+  _handleLegacyProjectDashboardRoute(data: PageContextWithQueryMap) {
+    this._redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
+  }
+
+  _handleGroupInfoRoute(data: PageContextWithQueryMap) {
+    this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
+  }
+
+  _handleGroupSelfRedirectRoute(_: PageContextWithQueryMap) {
+    this._redirect('/settings/#Groups');
+  }
+
+  _handleGroupRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.GROUP,
+      groupId: data.params[0] as GroupId,
+    });
+  }
+
+  _handleGroupAuditLogRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.GROUP,
+      detail: GroupDetailView.LOG,
+      groupId: data.params[0] as GroupId,
+    });
+  }
+
+  _handleGroupMembersRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.GROUP,
+      detail: GroupDetailView.MEMBERS,
+      groupId: data.params[0] as GroupId,
+    });
+  }
+
+  _handleGroupListOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-admin-group-list',
+      offset: data.params[1] || 0,
+      filter: null,
+      openCreateModal: data.hash === 'create',
+    });
+  }
+
+  _handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-admin-group-list',
+      offset: data.params['offset'],
+      filter: data.params['filter'],
+    });
+  }
+
+  _handleGroupListFilterRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-admin-group-list',
+      filter: data.params['filter'] || null,
+    });
+  }
+
+  _handleProjectsOldRoute(data: PageContextWithQueryMap) {
+    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}`);
+  }
+
+  _handleRepoCommandsRoute(data: PageContextWithQueryMap) {
+    const repo = data.params[0] as RepoName;
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.COMMANDS,
+      repo,
+    });
+    this.reporting.setRepoName(repo);
+  }
+
+  _handleRepoAccessRoute(data: PageContextWithQueryMap) {
+    const repo = data.params[0] as RepoName;
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.ACCESS,
+      repo,
+    });
+    this.reporting.setRepoName(repo);
+  }
+
+  _handleRepoDashboardsRoute(data: PageContextWithQueryMap) {
+    const repo = data.params[0] as RepoName;
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.DASHBOARDS,
+      repo,
+    });
+    this.reporting.setRepoName(repo);
+  }
+
+  _handleBranchListOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.BRANCHES,
+      repo: data.params[0] as RepoName,
+      offset: data.params[2] || 0,
+      filter: null,
+    });
+  }
+
+  _handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.BRANCHES,
+      repo: data.params['repo'] as RepoName,
+      offset: data.params['offset'],
+      filter: data.params['filter'],
+    });
+  }
+
+  _handleBranchListFilterRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.BRANCHES,
+      repo: data.params['repo'] as RepoName,
+      filter: data.params['filter'] || null,
+    });
+  }
+
+  _handleTagListOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.TAGS,
+      repo: data.params[0] as RepoName,
+      offset: data.params[2] || 0,
+      filter: null,
+    });
+  }
+
+  _handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.TAGS,
+      repo: data.params['repo'] as RepoName,
+      offset: data.params['offset'],
+      filter: data.params['filter'],
+    });
+  }
+
+  _handleTagListFilterRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.TAGS,
+      repo: data.params['repo'] as RepoName,
+      filter: data.params['filter'] || null,
+    });
+  }
+
+  _handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-repo-list',
+      offset: data.params[1] || 0,
+      filter: null,
+      openCreateModal: data.hash === 'create',
+    });
+  }
+
+  _handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-repo-list',
+      offset: data.params['offset'],
+      filter: data.params['filter'],
+    });
+  }
+
+  _handleRepoListFilterRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-repo-list',
+      filter: data.params['filter'] || null,
+    });
+  }
+
+  _handleCreateProjectRoute(_: PageContextWithQueryMap) {
+    // Redirects the legacy route to the new route, which displays the project
+    // list with a hash 'create'.
+    this._redirect('/admin/repos#create');
+  }
+
+  _handleCreateGroupRoute(_: PageContextWithQueryMap) {
+    // Redirects the legacy route to the new route, which displays the group
+    // list with a hash 'create'.
+    this._redirect('/admin/groups#create');
+  }
+
+  _handleRepoRoute(data: PageContextWithQueryMap) {
+    const repo = data.params[0] as RepoName;
+    this._setParams({
+      view: GerritView.REPO,
+      repo,
+    });
+    this.reporting.setRepoName(repo);
+  }
+
+  _handlePluginListOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-plugin-list',
+      offset: data.params[1] || 0,
+      filter: null,
+    });
+  }
+
+  _handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-plugin-list',
+      offset: data.params['offset'],
+      filter: data.params['filter'],
+    });
+  }
+
+  _handlePluginListFilterRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-plugin-list',
+      filter: data.params['filter'] || null,
+    });
+  }
+
+  _handlePluginListRoute(_: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.ADMIN,
+      adminView: 'gr-plugin-list',
+    });
+  }
+
+  _handleQueryRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.SEARCH,
+      query: data.params[0],
+      offset: data.params[2],
+    });
+  }
+
+  _handleChangeIdQueryRoute(data: PageContextWithQueryMap) {
+    // TODO(pcc): This will need to indicate that this was a change ID query if
+    // standard queries gain the ability to search places like commit messages
+    // for change IDs.
+    this._setParams({
+      view: GerritNav.View.SEARCH,
+      query: data.params[0],
+    });
+  }
+
+  _handleQueryLegacySuffixRoute(ctx: PageContextWithQueryMap) {
+    this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
+  }
+
+  _handleChangeNumberLegacyRoute(ctx: PageContextWithQueryMap) {
+    this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
+  }
+
+  _handleChangeRoute(ctx: PageContextWithQueryMap) {
+    // Parameter order is based on the regex group number matched.
+    const params: GenerateUrlChangeViewParameters = {
+      project: ctx.params[0] as RepoName,
+      // TODO(TS): remove as unknown
+      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      basePatchNum: convertToPatchSetNum(ctx.params[4]),
+      patchNum: convertToPatchSetNum(ctx.params[6]),
+      view: GerritView.CHANGE,
+      queryMap: ctx.queryMap,
+    };
+
+    this.reporting.setRepoName(params.project);
+    this._redirectOrNavigate(params);
+  }
+
+  _handleCommentRoute(ctx: PageContextWithQueryMap) {
+    const params: GenerateUrlDiffViewParameters = {
+      project: ctx.params[0] as RepoName,
+      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      commentId: ctx.params[2] as UrlEncodedCommentId,
+      view: GerritView.DIFF,
+      commentLink: true,
+    };
+    this.reporting.setRepoName(params.project);
+    this._redirectOrNavigate(params);
+  }
+
+  _handleDiffRoute(ctx: PageContextWithQueryMap) {
+    // Parameter order is based on the regex group number matched.
+    const params: GenerateUrlDiffViewParameters = {
+      project: ctx.params[0] as RepoName,
+      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      basePatchNum: convertToPatchSetNum(ctx.params[4]),
+      patchNum: convertToPatchSetNum(ctx.params[6]),
+      path: ctx.params[8],
+      view: GerritView.DIFF,
+    };
+    const address = this._parseLineAddress(ctx.hash);
+    if (address) {
+      params.leftSide = address.leftSide;
+      params.lineNum = address.lineNum;
+    }
+    this.reporting.setRepoName(params.project);
+    this._redirectOrNavigate(params);
+  }
+
+  _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
+    // Parameter order is based on the regex group number matched.
+    const params: GenerateUrlLegacyChangeViewParameters = {
+      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
+      basePatchNum: convertToPatchSetNum(ctx.params[3]),
+      patchNum: convertToPatchSetNum(ctx.params[5]),
+      view: GerritView.CHANGE,
+      querystring: ctx.querystring,
+    };
+
+    this._normalizeLegacyRouteParams(params);
+  }
+
+  _handleLegacyLinenum(ctx: PageContextWithQueryMap) {
+    this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
+  }
+
+  _handleDiffLegacyRoute(ctx: PageContextWithQueryMap) {
+    // Parameter order is based on the regex group number matched.
+    const params: GenerateUrlLegacyDiffViewParameters = {
+      // TODO(TS): remove "as unknown"
+      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
+      basePatchNum: convertToPatchSetNum(ctx.params[2]),
+      patchNum: convertToPatchSetNum(ctx.params[4]),
+      path: ctx.params[5],
+      view: GerritView.DIFF,
+    };
+
+    const address = this._parseLineAddress(ctx.hash);
+    if (address) {
+      params.leftSide = address.leftSide;
+      params.lineNum = address.lineNum;
+    }
+
+    this._normalizeLegacyRouteParams(params);
+  }
+
+  _handleDiffEditRoute(ctx: PageContextWithQueryMap) {
+    // Parameter order is based on the regex group number matched.
+    const project = ctx.params[0] as RepoName;
+    this._redirectOrNavigate({
+      project,
+      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      // for edit view params, patchNum cannot be undefined
+      patchNum: convertToPatchSetNum(ctx.params[2])!,
+      path: ctx.params[3],
+      lineNum: ctx.hash,
+      view: GerritView.EDIT,
+    });
+    this.reporting.setRepoName(project);
+  }
+
+  _handleChangeEditRoute(ctx: PageContextWithQueryMap) {
+    // Parameter order is based on the regex group number matched.
+    const project = ctx.params[0] as RepoName;
+    this._redirectOrNavigate({
+      project,
+      // TODO(TS): remove "as unknown"
+      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      patchNum: convertToPatchSetNum(ctx.params[3]),
+      view: GerritView.CHANGE,
+      edit: true,
+    });
+    this.reporting.setRepoName(project);
+  }
+
+  /**
+   * Normalize the patch range params for a the change or diff view and
+   * redirect if URL upgrade is needed.
+   */
+  _redirectOrNavigate(params: GenerateUrlParameters & PatchRangeParams) {
+    const needsRedirect = this._normalizePatchRangeParams(params);
+    if (needsRedirect) {
+      this._redirect(this._generateUrl(params));
+    } else {
+      this._setParams(params);
+    }
+  }
+
+  _handleAgreementsRoute() {
+    this._redirect('/settings/#Agreements');
+  }
+
+  _handleNewAgreementsRoute(data: PageContextWithQueryMap) {
+    data.params['view'] = GerritView.AGREEMENTS;
+    // TODO(TS): create valid object
+    this._setParams((data.params as unknown) as AppElementAgreementParam);
+  }
+
+  _handleSettingsLegacyRoute(data: PageContextWithQueryMap) {
+    // 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: GerritView.SETTINGS,
+      emailToken: token,
+    });
+  }
+
+  _handleSettingsRoute(_: PageContextWithQueryMap) {
+    this._setParams({view: GerritView.SETTINGS});
+  }
+
+  _handleRegisterRoute(ctx: PageContextWithQueryMap) {
+    this._setParams({justRegistered: true});
+    let path = ctx.params[0] || '/';
+
+    // Prevent redirect looping.
+    if (path.startsWith('/register')) {
+      path = '/';
+    }
+
+    if (path[0] !== '/') {
+      return;
+    }
+    this._redirect(getBaseUrl() + path);
+  }
+
+  /**
+   * Handler for routes that should pass through the router and not be caught
+   * by the catchall _handleDefaultRoute handler.
+   */
+  _handlePassThroughRoute() {
+    location.reload();
+  }
+
+  /**
+   * URL may sometimes have /+/ encoded to / /.
+   * Context: Issue 6888, Issue 7100
+   */
+  _handleImproperlyEncodedPlusRoute(ctx: PageContextWithQueryMap) {
+    let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
+    if (hash.length) {
+      hash = '#' + hash;
+    }
+    this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
+  }
+
+  _handlePluginScreen(ctx: PageContextWithQueryMap) {
+    const view = GerritView.PLUGIN_SCREEN;
+    const plugin = ctx.params[0];
+    const screen = ctx.params[1];
+    this._setParams({view, plugin, screen});
+  }
+
+  _handleDocumentationSearchRoute(data: PageContextWithQueryMap) {
+    this._setParams({
+      view: GerritView.DOCUMENTATION_SEARCH,
+      filter: data.params['filter'] || null,
+    });
+  }
+
+  _handleDocumentationSearchRedirectRoute(data: PageContextWithQueryMap) {
+    this._redirect(
+      '/Documentation/q/filter:' + encodeURIComponent(data.params[0])
+    );
+  }
+
+  _handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
+    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}}})
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-router': 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
deleted file mode 100644
index 07f067e..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
new file mode 100644
index 0000000..91d8b41
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
@@ -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';
+
+export const htmlTemplate = html`
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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
deleted file mode 100644
index 2b2db0b..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ /dev/null
@@ -1,1661 +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">
-<meta charset="utf-8">
-<title>gr-router</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-router></gr-router>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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,
-          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 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: 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('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+-is:wip'));
-        });
-      });
-
-      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'));
-        });
-      });
-    });
-
-    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: 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,
-        });
-
-        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('_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('_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, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 0,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          data.params[1] = 42;
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          data.hash = 'create';
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.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: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleRepoListFilterRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            filter: null,
-          });
-
-          data.params.filter = 'foo';
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            filter: 'foo',
-          });
-        });
-      });
-    });
-
-    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('_handlePluginListFilterOffsetRoute', () => {
-        const data = {params: {filter: 'foo', offset: 42}};
-        assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 42,
-          filter: 'foo',
-        });
-      });
-
-      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-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
new file mode 100644
index 0000000..927434b
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -0,0 +1,1676 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-router.js';
+import {page} from '../../../utils/page-wrapper-utils.js';
+import {GerritNav} from '../gr-navigation/gr-navigation.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+import {_testOnly_RoutePattern} from './gr-router.js';
+
+const basicFixture = fixtureFromElement('gr-router');
+
+suite('gr-router tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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}};
+    sinon.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}}; };
+    sinon.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 = {};
+    sinon.stub(GerritNav, 'setup');
+    sinon.stub(page, 'start');
+    sinon.stub(page, 'base');
+    sinon.stub(element, '_mapRoute').callsFake(
+        (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',
+      '_handleChangeIdQueryRoute',
+      '_handleChangeNumberLegacyRoute',
+      '_handleChangeRoute',
+      '_handleCommentRoute',
+      '_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', () => {
+    sinon.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(true));
+    const data = {canonicalPath: ''};
+    const redirectStub = sinon.stub(element, '_redirectToLogin');
+    return element._redirectIfNotLoggedIn(data).then(() => {
+      assert.isFalse(redirectStub.called);
+    });
+  });
+
+  test('_redirectIfNotLoggedIn while logged out', () => {
+    sinon.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(false));
+    const redirectStub = sinon.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;
+    let generateUrlStub;
+
+    setup(() => {
+      projectLookupStub = sinon
+          .stub(element.$.restAPI, 'getFromProjectLookup');
+      generateUrlStub = sinon.stub(element, '_generateUrl');
+    });
+
+    suite('_normalizeLegacyRouteParams', () => {
+      let rangeStub;
+      let redirectStub;
+      let show404Stub;
+
+      setup(() => {
+        rangeStub = sinon.stub(element, '_normalizePatchRangeParams')
+            .returns(Promise.resolve());
+        redirectStub = sinon.stub(element, '_redirect');
+        show404Stub = sinon.stub(element, '_show404');
+      });
+
+      test('w/o changeNum', () => {
+        projectLookupStub.returns(Promise.resolve('foo/bar'));
+        const params = {};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isFalse(generateUrlStub.calledOnce);
+          assert.isFalse(projectLookupStub.called);
+          assert.isFalse(rangeStub.called);
+          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(generateUrlStub.calledOnce);
+          const updatedParams = generateUrlStub.lastCall.args[0];
+          assert.isTrue(projectLookupStub.called);
+          assert.isTrue(rangeStub.called);
+          assert.equal(updatedParams.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.isFalse(generateUrlStub.calledOnce);
+          assert.isTrue(projectLookupStub.called);
+          assert.isFalse(rangeStub.called);
+          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 = sinon.stub(element, '_redirect');
+      setParamsStub = sinon.stub(element, '_setParams');
+      handlePassThroughRoute = sinon.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;
+      };
+      sinon.stub(page, 'exit').callsFake( onRegisteringExit);
+      sinon.stub(GerritNav, 'setup');
+      sinon.stub(page, 'start');
+      sinon.stub(page, 'base');
+      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');
+
+      sinon.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',
+      });
+    });
+
+    test('_handleChangeIdQueryRoute', () => {
+      const data = {params: ['I0123456789abcdef0123456789abcdef01234567']};
+      assertDataToParams(data, '_handleChangeIdQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'I0123456789abcdef0123456789abcdef01234567',
+      });
+    });
+
+    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 = sinon.stub(window, 'close');
+        const result = element._handleRootRoute(data);
+        assert.isNotOk(result);
+        assert.isTrue(closeStub.called);
+        assert.isFalse(redirectStub.called);
+      });
+
+      test('redirects to dashboard if logged in', () => {
+        sinon.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', () => {
+        sinon.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+-is:wip'));
+        });
+      });
+
+      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',
+          };
+          stubBaseUrl('/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 = sinon.stub(element, '_redirectToLogin');
+      });
+
+      test('own dashboard but signed out redirects to login', () => {
+        sinon.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', () => {
+        sinon.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', () => {
+        sinon.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 = sinon.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,
+        });
+
+        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('_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('_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, '_handleRepoListOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: 0,
+            filter: null,
+            openCreateModal: false,
+          });
+
+          data.params[1] = 42;
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: 42,
+            filter: null,
+            openCreateModal: false,
+          });
+
+          data.hash = 'create';
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+            view: GerritNav.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: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: 42,
+            filter: 'foo',
+          });
+        });
+
+        test('_handleRepoListFilterRoute', () => {
+          const data = {params: {}};
+          assertDataToParams(data, '_handleRepoListFilterRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            filter: null,
+          });
+
+          data.params.filter = 'foo';
+          assertDataToParams(data, '_handleRepoListFilterRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            filter: 'foo',
+          });
+        });
+      });
+    });
+
+    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('_handlePluginListFilterOffsetRoute', () => {
+        const data = {params: {filter: 'foo', offset: 42}};
+        assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 42,
+          filter: 'foo',
+        });
+      });
+
+      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 = sinon.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 = sinon.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 = sinon.stub(element,
+              '_normalizePatchRangeParams');
+          sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sinon.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);
+          sinon.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 = sinon.stub(element,
+              '_normalizePatchRangeParams');
+          sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sinon.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);
+          sinon.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('comment route', () => {
+          const url = '/c/gerrit/+/264833/comment/00049681_f34fd6a9/';
+          const groups = url.match(_testOnly_RoutePattern.COMMENT);
+          assert.deepEqual(groups.slice(1), [
+            'gerrit', // project
+            '264833', // changeNum
+            '00049681_f34fd6a9', // commentId
+          ]);
+          assertDataToParams({params: groups.slice(1)}, '_handleCommentRoute', {
+            project: 'gerrit',
+            changeNum: '264833',
+            commentId: '00049681_f34fd6a9',
+            commentLink: true,
+            view: GerritNav.View.DIFF,
+          });
+        });
+      });
+
+      test('_handleDiffEditRoute', () => {
+        const normalizeRangeSpy =
+            sinon.spy(element, '_normalizePatchRangeParams');
+        sinon.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 =
+            sinon.spy(element, '_normalizePatchRangeParams');
+        sinon.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 =
+            sinon.spy(element, '_normalizePatchRangeParams');
+        sinon.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', '']]);
+    });
+  });
+});
+
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
deleted file mode 100644
index a06fd85..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ /dev/null
@@ -1,343 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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:',
-  '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_SET =
-  new Set(SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`)));
-
-const MAX_AUTOCOMPLETE_RESULTS = 10;
-
-const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
-
-/**
- * @extends Polymer.Element
- */
-class GrSearchBar extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-search-bar'; }
-  /**
-   * Fired when a search is committed
-   *
-   * @event handle-search
-   */
-
-  static get properties() {
-    return {
-      value: {
-        type: String,
-        value: '',
-        notify: true,
-        observer: '_valueChanged',
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      query: {
-        type: Function,
-        value() {
-          return this._getSearchSuggestions.bind(this);
-        },
-      },
-      projectSuggestions: {
-        type: Function,
-        value() {
-          return () => Promise.resolve([]);
-        },
-      },
-      groupSuggestions: {
-        type: Function,
-        value() {
-          return () => Promise.resolve([]);
-        },
-      },
-      accountSuggestions: {
-        type: Function,
-        value() {
-          return () => Promise.resolve([]);
-        },
-      },
-      _inputVal: String,
-      _threshold: {
-        type: Number,
-        value: 1,
-      },
-    };
-  }
-
-  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');
-      }
-    });
-  }
-
-  _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);
-
-      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);
-
-      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();
-
-    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; }
-
-    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.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
new file mode 100644
index 0000000..abbe316
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -0,0 +1,411 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-search-bar_html';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {ServerInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+  GrAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {getDocsBaseUrl} from '../../../utils/url-util';
+import {CustomKeyboardEvent} from '../../../types/events';
+import {MergeabilityComputationBehavior} from '../../../constants/constants';
+
+// Possible static search options for auto complete, without negations.
+const SEARCH_OPERATORS: ReadonlyArray<string> = [
+  'added:',
+  'after:',
+  'age:',
+  'age:1week', // Give an example age
+  'assignee:',
+  'attention:',
+  'author:',
+  'before:',
+  '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:',
+  'mergedafter:',
+  'mergedbefore:',
+  '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_SET: ReadonlySet<string> = new Set(
+  SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`))
+);
+
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+
+const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+
+export type SuggestionProvider = (
+  predicate: string,
+  expression: string
+) => Promise<AutocompleteSuggestion[]>;
+
+export interface SearchBarHandleSearchDetail {
+  inputVal: string;
+}
+
+export interface GrSearchBar {
+  $: {
+    restAPI: RestApiService & Element;
+    searchInput: GrAutocomplete;
+  };
+}
+
+@customElement('gr-search-bar')
+export class GrSearchBar extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  private searchOperators = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
+
+  /**
+   * Fired when a search is committed
+   *
+   * @event handle-search
+   */
+
+  @property({type: String, notify: true, observer: '_valueChanged'})
+  value = '';
+
+  @property({type: Object})
+  keyEventTarget: unknown = document.body;
+
+  @property({type: Object})
+  query: AutocompleteQuery;
+
+  @property({type: Object})
+  projectSuggestions: SuggestionProvider = () => Promise.resolve([]);
+
+  @property({type: Object})
+  groupSuggestions: SuggestionProvider = () => Promise.resolve([]);
+
+  @property({type: Object})
+  accountSuggestions: SuggestionProvider = () => Promise.resolve([]);
+
+  @property({type: String})
+  _inputVal?: string;
+
+  @property({type: Number})
+  _threshold = 1;
+
+  @property({type: String})
+  label = '';
+
+  @property({type: String})
+  docBaseUrl: string | null = null;
+
+  constructor() {
+    super();
+    this.query = (input: string) => this._getSearchSuggestions(input);
+  }
+
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then((serverConfig?: ServerInfo) => {
+      const mergeability =
+        serverConfig &&
+        serverConfig.change &&
+        serverConfig.change.mergeability_computation_behavior;
+      if (
+        mergeability ===
+          MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX ||
+        mergeability ===
+          MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX
+      ) {
+        // add 'is:mergeable' to searchOperators
+        this._addOperator('is:mergeable');
+      }
+      if (serverConfig) {
+        getDocsBaseUrl(serverConfig, this.$.restAPI).then(baseUrl => {
+          this.docBaseUrl = baseUrl;
+        });
+      }
+    });
+  }
+
+  _computeHelpDocLink(docBaseUrl: string | null) {
+    // fallback to gerrit's official doc
+    let baseUrl =
+      docBaseUrl || 'https://gerrit-review.googlesource.com/documentation/';
+    if (baseUrl.endsWith('/')) {
+      baseUrl = baseUrl.substring(0, baseUrl.length - 1);
+    }
+    return `${baseUrl}/user-search.html`;
+  }
+
+  _addOperator(name: string, include_neg = true) {
+    this.searchOperators.add(name);
+    if (include_neg) {
+      this.searchOperators.add(`-${name}`);
+    }
+  }
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.SEARCH]: '_handleSearch',
+    };
+  }
+
+  _valueChanged(value: string) {
+    this._inputVal = value;
+  }
+
+  _handleInputCommit(e: Event) {
+    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
+   */
+  _preventDefaultAndNavigateToInputVal(e: Event) {
+    e.preventDefault();
+    const target = (dom(e) as EventApi).rootTarget as PolymerElement;
+    // 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'] as HTMLElement).blur();
+    } else {
+      target.blur();
+    }
+    if (!this._inputVal) return;
+    const trimmedInput = this._inputVal.trim();
+    if (trimmedInput) {
+      const predefinedOpOnlyQuery = [...this.searchOperators].some(
+        op => op.endsWith(':') && op === trimmedInput
+      );
+      if (predefinedOpOnlyQuery) {
+        return;
+      }
+      const detail: SearchBarHandleSearchDetail = {
+        inputVal: this._inputVal,
+      };
+      this.dispatchEvent(
+        new CustomEvent('handle-search', {
+          detail,
+        })
+      );
+    }
+  }
+
+  /**
+   * Determine what array of possible suggestions should be provided
+   * to _getSearchSuggestions.
+   *
+   * @param input - The full search term, in lowercase.
+   * @return This returns a promise that resolves to an array of
+   * suggestion objects.
+   */
+  _fetchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+    // 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 'assignee':
+      case 'attention':
+      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(
+          [...this.searchOperators]
+            .filter(operator => operator.includes(input))
+            .map(operator => {
+              return {text: operator};
+            })
+        );
+    }
+  }
+
+  /**
+   * Get the sorted, pruned list of suggestions for the current search query.
+   *
+   * @param input - The complete search query.
+   * @return This returns a promise that resolves to an array of
+   * suggestions.
+   */
+  _getSearchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+    // Allow spaces within quoted terms.
+    const tokens = input.match(TOKENIZE_REGEX);
+    if (tokens === null) return Promise.resolve([]);
+    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 === undefined && bContains === undefined) return 0;
+            if (aContains === undefined && bContains !== undefined) return 1;
+            if (aContains !== undefined && bContains === undefined) return -1;
+            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: CustomKeyboardEvent) {
+    const keyboardEvent = this.getKeyboardEvent(e);
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) && !keyboardEvent.shiftKey)
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.searchInput.focus();
+    this.$.searchInput.selectAll();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-search-bar': 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
deleted file mode 100644
index e26f8a3..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
new file mode 100644
index 0000000..2fbdc7e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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
+      label="[[label]]"
+      show-search-icon=""
+      id="searchInput"
+      text="{{_inputVal}}"
+      query="[[query]]"
+      on-commit="_handleInputCommit"
+      allow-non-suggested-values=""
+      multi=""
+      threshold="[[_threshold]]"
+      tab-complete=""
+      vertical-offset="30"
+    >
+      <a
+        slot="suffix"
+        href$="[[_computeHelpDocLink(docBaseUrl)]]"
+        target="_blank"
+        class="help"
+      >
+        <iron-icon
+          icon="gr-icons:help-outline"
+          title="read documentation"
+        ></iron-icon>
+      </a>
+    </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
deleted file mode 100644
index 3b37e09..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ /dev/null
@@ -1,239 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-search-bar</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/page/page.js"></script>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-search-bar.js';
-void (0);
-</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-search-bar></gr-search-bar>
-  </template>
-</test-fixture>
-
-<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;
-
-  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');
-      });
-    });
-
-    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);
-        done();
-      });
-    });
-
-    test('Autocompltes without is:mergable when disabled', done => {
-      element._getSearchSuggestions('is:mergeab').then(s => {
-        assert.equal(s.length, 0);
-        done();
-      });
-    });
-  });
-
-  [
-    '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('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-search-bar/gr-search-bar_test.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
new file mode 100644
index 0000000..e470618
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
@@ -0,0 +1,252 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-search-bar.js';
+import '../../../scripts/util.js';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util.js';
+
+const basicFixture = fixtureFromElement('gr-search-bar');
+
+suite('gr-search-bar tests', () => {
+  let element;
+
+  suiteSetup(() => {
+    const kb = TestKeyboardShortcutBinder.push();
+    kb.bindShortcut(Shortcut.SEARCH, '/');
+  });
+
+  suiteTeardown(() => {
+    TestKeyboardShortcutBinder.pop();
+  });
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    flush(done);
+  });
+
+  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 = sinon.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 = sinon.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 = sinon.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 = sinon.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 = sinon.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 = sinon.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 = sinon.spy(element.$.searchInput, 'focus');
+    const selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
+    MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
+    assert.isTrue(focusSpy.called);
+    assert.isTrue(selectAllSpy.called);
+  });
+
+  suite('_getSearchSuggestions', () => {
+    test('Autocompletes accounts', () => {
+      sinon.stub(element, 'accountSuggestions').callsFake(() =>
+        Promise.resolve([{text: 'owner:fred@goog.co'}])
+      );
+      return element._getSearchSuggestions('owner:fr').then(s => {
+        assert.equal(s[0].value, 'owner:fred@goog.co');
+      });
+    });
+
+    test('Autocompletes groups', done => {
+      sinon.stub(element, 'groupSuggestions').callsFake(() =>
+        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 => {
+      sinon.stub(element, 'projectSuggestions').callsFake(() =>
+        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);
+        done();
+      });
+    });
+
+    test('Autocompletes without is:mergable when disabled', async () => {
+      const s = await element._getSearchSuggestions('is:mergeab');
+      assert.isEmpty(s);
+    });
+  });
+
+  [
+    '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({
+              change: {
+                mergeability_computation_behavior: mergeability,
+              },
+            });
+          },
+        });
+
+        element = basicFixture.instantiate();
+        flush(done);
+      });
+
+      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();
+        });
+      });
+    });
+  });
+
+  suite('doc url', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getConfig() {
+          return Promise.resolve({
+            gerrit: {
+              doc_url: 'https://doc.com/',
+            },
+          });
+        },
+      });
+
+      _testOnly_clearDocsBaseUrlCache();
+      element = basicFixture.instantiate();
+      flush(done);
+    });
+
+    test('compute help doc url with correct path', () => {
+      assert.equal(element.docBaseUrl, 'https://doc.com/');
+      assert.equal(
+          element._computeHelpDocLink(element.docBaseUrl),
+          'https://doc.com/user-search.html'
+      );
+    });
+
+    test('compute help doc url fallback to gerrit url', () => {
+      assert.equal(
+          element._computeHelpDocLink(),
+          'https://gerrit-review.googlesource.com/documentation/' +
+          'user-search.html'
+      );
+    });
+  });
+});
+
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
deleted file mode 100644
index c1b2e42..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const MAX_AUTOCOMPLETE_RESULTS = 10;
-const SELF_EXPRESSION = 'self';
-const ME_EXPRESSION = 'me';
-
-/**
- * @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: {
-        type: Function,
-        value() {
-          return this._fetchProjects.bind(this);
-        },
-      },
-      _groupSuggestions: {
-        type: Function,
-        value() {
-          return this._fetchGroups.bind(this);
-        },
-      },
-      _accountSuggestions: {
-        type: Function,
-        value() {
-          return this._fetchAccounts.bind(this);
-        },
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.$.restAPI.getConfig().then(cfg => {
-      this._config = cfg;
-    });
-  }
-
-  _handleSearch(e) {
-    const input = e.detail.inputVal;
-    if (input) {
-      GerritNav.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}; });
-        });
-  }
-
-  /**
-   * 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,
-        undefined,
-        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 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 => {
-      const userName = this.getUserName(this._serverConfig, account);
-      return {
-        label: account.name || '',
-        text: account.email ?
-          `${predicate}:${account.email}` :
-          `${predicate}:"${userName}"`,
-      };
-    });
-  }
-}
-
-customElements.define(GrSmartSearch.is, GrSmartSearch);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
new file mode 100644
index 0000000..6b20c28
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -0,0 +1,197 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-search-bar/gr-search-bar';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-smart-search_html';
+import {GerritNav} from '../gr-navigation/gr-navigation';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {
+  SearchBarHandleSearchDetail,
+  SuggestionProvider,
+} from '../gr-search-bar/gr-search-bar';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+const SELF_EXPRESSION = 'self';
+const ME_EXPRESSION = 'me';
+
+export interface GrSmartSearch {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-smart-search')
+export class GrSmartSearch extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  searchQuery?: string;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  @property({type: Object})
+  _projectSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchProjects(predicate, expression);
+
+  @property({type: Object})
+  _groupSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchGroups(predicate, expression);
+
+  @property({type: Object})
+  _accountSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchAccounts(predicate, expression);
+
+  @property({type: String})
+  label = '';
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(cfg => {
+      this._config = cfg;
+    });
+  }
+
+  _handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
+    const input = e.detail.inputVal;
+    if (input) {
+      GerritNav.navigateToSearchQuery(input);
+    }
+  }
+
+  /**
+   * Fetch from the API the predicted projects.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'project'
+   * @param expression - The second part of the search term, e.g.
+   * 'gerr'
+   */
+  _fetchProjects(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    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};
+        });
+      });
+  }
+
+  /**
+   * Fetch from the API the predicted groups.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'ownerin'
+   * @param expression - The second part of the search term, e.g.
+   * 'polyger'
+   */
+  _fetchGroups(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    if (expression.length === 0) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI
+      .getSuggestedGroups(expression, undefined, 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 accounts.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'owner'
+   * @param expression - The second part of the search term, e.g.
+   * 'kasp'
+   */
+  _fetchAccounts(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    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: AccountInfo[],
+    predicate: string
+  ): AutocompleteSuggestion[] {
+    return accounts.map(account => {
+      const userName = getUserName(this._config, account);
+      return {
+        label: account.name || '',
+        text: account.email
+          ? `${predicate}:${account.email}`
+          : `${predicate}:"${userName}"`,
+      };
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-smart-search': 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
deleted file mode 100644
index bb741ce..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
new file mode 100644
index 0000000..7088937
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
@@ -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';
+
+export const htmlTemplate = html`
+  <style include="shared-styles"></style>
+  <gr-search-bar
+    id="search"
+    label="[[label]]"
+    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
deleted file mode 100644
index 87dfaf4..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
+++ /dev/null
@@ -1,156 +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">
-<meta charset="utf-8">
-<title>gr-smart-search</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-smart-search></gr-smart-search>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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: ''});
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
new file mode 100644
index 0000000..dc7bf0e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-smart-search.js';
+
+const basicFixture = fixtureFromElement('gr-smart-search');
+
+suite('gr-smart-search tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('Autocompletes accounts', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake(() =>
+      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', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+      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', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+      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', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedGroups').callsFake( () =>
+      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', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedProjects').callsFake( () =>
+      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', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedGroups').callsFake( () =>
+      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', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+      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', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+      Promise.resolve([{email: 'fred@goog.co'}]));
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.html b/polygerrit-ui/app/elements/custom-dark-theme_test.html
deleted file mode 100644
index 6ac9c20..0000000
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.html
+++ /dev/null
@@ -1,103 +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">
-<meta charset="utf-8">
-<title>gr-app-it_test</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="element">
-  <template>
-    <gr-app id="app"></gr-app>
-  </template>
-</test-fixture>
-
-<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';
-
-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);
-          });
-    });
-  });
-
-  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-dark-theme_test.js b/polygerrit-ui/app/elements/custom-dark-theme_test.js
new file mode 100644
index 0000000..ad12e14
--- /dev/null
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.js
@@ -0,0 +1,64 @@
+/**
+ * @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 '../test/common-test-setup-karma.js';
+import {getComputedStyleValue} from '../utils/dom-util.js';
+import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import './gr-app.js';
+import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+import {removeTheme} from '../styles/themes/dark-theme.js';
+
+const basicFixture = fixtureFromElement('gr-app');
+
+suite('gr-app custom dark theme tests', () => {
+  let element;
+  setup(done => {
+    window.localStorage.setItem('dark-theme', 'true');
+
+    element = basicFixture.instantiate();
+    getPluginLoader().loadPlugins([]);
+    getPluginLoader().awaitPluginsLoaded()
+        .then(() => flush(done));
+  });
+
+  teardown(() => {
+    window.localStorage.removeItem('dark-theme');
+    removeTheme();
+    // The app sends requests to server. This can lead to
+    // unexpected gr-alert elements in document.body
+    document.body.querySelectorAll('gr-alert').forEach(grAlert => {
+      grAlert.remove();
+    });
+  });
+
+  test('should tried to load dark theme', () => {
+    assert.isTrue(
+        !!document.head.querySelector('#dark-theme')
+    );
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        getComputedStyleValue('--header-background-color', element)
+            .toLowerCase(),
+        '#3b3d3f');
+    assert.equal(
+        getComputedStyleValue('--footer-background-color', element)
+            .toLowerCase(),
+        '#3b3d3f');
+  });
+});
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.html b/polygerrit-ui/app/elements/custom-light-theme_test.html
deleted file mode 100644
index f8a749c..0000000
--- a/polygerrit-ui/app/elements/custom-light-theme_test.html
+++ /dev/null
@@ -1,103 +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">
-<meta charset="utf-8">
-<title>gr-app-it_test</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="element">
-  <template>
-    <gr-app id="app"></gr-app>
-  </template>
-</test-fixture>
-
-<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';
-
-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);
-          });
-    });
-  });
-
-  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/custom-light-theme_test.js b/polygerrit-ui/app/elements/custom-light-theme_test.js
new file mode 100644
index 0000000..6d5b61e
--- /dev/null
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.js
@@ -0,0 +1,66 @@
+/**
+ * @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 '../test/common-test-setup-karma.js';
+import {getComputedStyleValue} from '../utils/dom-util.js';
+import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import './gr-app.js';
+import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+
+const basicFixture = fixtureFromElement('gr-app');
+
+suite('gr-app custom light theme tests', () => {
+  let element;
+  setup(done => {
+    window.localStorage.removeItem('dark-theme');
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve({}); },
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+    getPluginLoader().loadPlugins([]);
+    getPluginLoader().awaitPluginsLoaded()
+        .then(() => flush(done));
+  });
+  teardown(() => {
+    // The app sends requests to server. This can lead to
+    // unexpected gr-alert elements in document.body
+    document.body.querySelectorAll('gr-alert').forEach(grAlert => {
+      grAlert.remove();
+    });
+  });
+
+  test('should not load dark theme', () => {
+    assert.isFalse(!!document.head.querySelector('#dark-theme'));
+    assert.isTrue(!!document.head.querySelector('#light-theme'));
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        getComputedStyleValue('--header-background-color', element)
+            .toLowerCase(),
+        '#f1f3f4');
+    assert.equal(
+        getComputedStyleValue('--footer-background-color', element)
+            .toLowerCase(),
+        'transparent');
+  });
+});
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
deleted file mode 100644
index 9beb243..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
+++ /dev/null
@@ -1,252 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
new file mode 100644
index 0000000..0e73516
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -0,0 +1,314 @@
+/**
+ * @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-icon/iron-icon';
+import '../../../styles/shared-styles';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-diff/gr-diff';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-apply-fix-dialog_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  NumericChangeId,
+  DiffInfo,
+  DiffPreferencesInfo,
+  EditPatchSetNum,
+  FixId,
+  FixSuggestionInfo,
+  PatchSetNum,
+  RobotId,
+} from '../../../types/common';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {isRobot} from '../../../utils/comment-util';
+import {OpenFixPreviewEvent} from '../../../types/events';
+
+export interface GrApplyFixDialog {
+  $: {
+    restAPI: RestApiService & Element;
+    applyFixOverlay: GrOverlay;
+  };
+}
+
+interface FilePreview {
+  filepath: string;
+  preview: DiffInfo;
+}
+
+@customElement('gr-apply-fix-dialog')
+export class GrApplyFixDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  prefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: String})
+  changeNum?: NumericChangeId;
+
+  @property({type: Number})
+  _patchNum?: PatchSetNum;
+
+  @property({type: String})
+  _robotId?: RobotId;
+
+  @property({type: Object})
+  _currentFix?: FixSuggestionInfo;
+
+  @property({type: Array})
+  _currentPreviews: FilePreview[] = [];
+
+  @property({type: Array})
+  _fixSuggestions?: FixSuggestionInfo[];
+
+  @property({type: Boolean})
+  _isApplyFixLoading = false;
+
+  @property({type: Number})
+  _selectedFixIdx = 0;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeDisableApplyFixButton(_isApplyFixLoading, change, ' +
+      '_patchNum)',
+  })
+  _disableApplyFixButton?: boolean;
+
+  private refitOverlay?: () => void;
+
+  /**
+   * Given robot comment CustomEvent object, fetch diffs associated
+   * with first robot comment suggested fix and open dialog.
+   *
+   * @param e to be passed from gr-comment with robot comment detail.
+   * @return Promise that resolves either when all
+   * preview diffs are fetched or no fix suggestions in custom event detail.
+   */
+  open(e: OpenFixPreviewEvent) {
+    const detail = e.detail;
+    const comment = detail.comment;
+    if (!detail.patchNum || !comment || !isRobot(comment)) {
+      return Promise.resolve();
+    }
+    this._patchNum = detail.patchNum;
+    this._fixSuggestions = comment.fix_suggestions;
+    this._robotId = comment.robot_id;
+    if (!this._fixSuggestions || !this._fixSuggestions.length) {
+      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();
+    if (this.refitOverlay) {
+      this.removeEventListener('diff-context-expanded', this.refitOverlay);
+    }
+  }
+
+  _showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
+    this._currentFix = fixSuggestion;
+    return this._fetchFixPreview(fixSuggestion.fix_id);
+  }
+
+  _fetchFixPreview(fixId: FixId) {
+    if (!this.changeNum || !this._patchNum) {
+      return Promise.reject(
+        new Error('Both _patchNum and changeNum must be set')
+      );
+    }
+    return this.$.restAPI
+      .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
+      .then(res => {
+        if (res) {
+          this._currentPreviews = Object.keys(res).map(key => {
+            return {filepath: key, preview: res[key]};
+          });
+        }
+      })
+      .catch(err => {
+        this._close();
+        throw err;
+      });
+  }
+
+  hasSingleFix(_fixSuggestions?: FixSuggestionInfo[]) {
+    return (_fixSuggestions || []).length === 1;
+  }
+
+  overridePartialPrefs(prefs: DiffPreferencesInfo): DiffPreferencesInfo {
+    // generate a smaller gr-diff than fullscreen for dialog
+    return {...prefs, line_length: 50};
+  }
+
+  onCancel(e: CustomEvent) {
+    if (e) {
+      e.stopPropagation();
+    }
+    this._close();
+  }
+
+  addOneTo(_selectedFixIdx: number) {
+    return _selectedFixIdx + 1;
+  }
+
+  _onPrevFixClick(e: CustomEvent) {
+    if (e) e.stopPropagation();
+    if (this._selectedFixIdx >= 1 && this._fixSuggestions) {
+      this._selectedFixIdx -= 1;
+      this._showSelectedFixSuggestion(
+        this._fixSuggestions[this._selectedFixIdx]
+      );
+    }
+  }
+
+  _onNextFixClick(e: CustomEvent) {
+    if (e) e.stopPropagation();
+    if (
+      this._fixSuggestions &&
+      this._selectedFixIdx < this._fixSuggestions.length
+    ) {
+      this._selectedFixIdx += 1;
+      this._showSelectedFixSuggestion(
+        this._fixSuggestions[this._selectedFixIdx]
+      );
+    }
+  }
+
+  _noPrevFix(_selectedFixIdx: number) {
+    return _selectedFixIdx === 0;
+  }
+
+  _noNextFix(_selectedFixIdx: number, fixSuggestions?: FixSuggestionInfo[]) {
+    if (!fixSuggestions) return true;
+    return _selectedFixIdx === fixSuggestions.length - 1;
+  }
+
+  _close() {
+    this._currentFix = undefined;
+    this._currentPreviews = [];
+    this._isApplyFixLoading = false;
+
+    this.dispatchEvent(
+      new CustomEvent('close-fix-preview', {
+        bubbles: true,
+        composed: true,
+      })
+    );
+    this.$.applyFixOverlay.close();
+  }
+
+  _getApplyFixButtonLabel(isLoading: boolean) {
+    return isLoading ? 'Saving...' : 'Apply Fix';
+  }
+
+  _computeTooltip(change?: ParsedChangeInfo, patchNum?: PatchSetNum) {
+    if (!change || !patchNum) return '';
+    const latestPatchNum = change.revisions[change.current_revision]._number;
+    return latestPatchNum !== patchNum
+      ? 'Fix can only be applied to the latest patchset'
+      : '';
+  }
+
+  _computeDisableApplyFixButton(
+    isApplyFixLoading?: boolean,
+    change?: ParsedChangeInfo,
+    patchNum?: PatchSetNum
+  ) {
+    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: CustomEvent) {
+    if (e) {
+      e.stopPropagation();
+    }
+
+    const changeNum = this.changeNum;
+    const patchNum = this._patchNum;
+    const change = this.change;
+    if (!changeNum || !patchNum || !change || !this._currentFix) {
+      return Promise.reject(new Error('Not all required properties are set.'));
+    }
+    this._isApplyFixLoading = true;
+    return this.$.restAPI
+      .applyFixSuggestion(changeNum, patchNum, this._currentFix.fix_id)
+      .then(res => {
+        if (res && res.ok) {
+          GerritNav.navigateToChange(change, EditPatchSetNum, patchNum);
+          this._close();
+        }
+        this._isApplyFixLoading = false;
+      });
+  }
+
+  getFixDescription(currentFix?: FixSuggestionInfo) {
+    return currentFix && currentFix.description ? currentFix.description : '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-apply-fix-dialog': 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
deleted file mode 100644
index a5a6ff2..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
new file mode 100644
index 0000000..057fd01
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 8874f71..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html
+++ /dev/null
@@ -1,323 +0,0 @@
-<!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-apply-fix-dialog/gr-apply-fix-dialog_test.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
new file mode 100644
index 0000000..cb73885
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
@@ -0,0 +1,298 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-apply-fix-dialog.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-apply-fix-dialog');
+
+suite('gr-apply-fix-dialog tests', () => {
+  let element;
+
+  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(() => {
+    element = basicFixture.instantiate();
+    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,
+    };
+  });
+
+  suite('dialog open', () => {
+    setup(() => {
+      sinon.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'],
+                },
+              ],
+            },
+          }));
+      sinon.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 => {
+    sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+        .returns(Promise.resolve({}));
+    sinon.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 => {
+    sinon.stub(window, 'fetch').callsFake((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.equal(element._currentFix, undefined);
+      done();
+    });
+  });
+
+  test('apply fix button should call apply ' +
+  'and navigate to change view', () => {
+    sinon.stub(element.$.restAPI, 'applyFixSuggestion')
+        .returns(Promise.resolve({ok: true}));
+    sinon.stub(GerritNav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+
+    return 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.equal(element._currentFix, undefined);
+      assert.equal(element._currentPreviews.length, 0);
+    });
+  });
+
+  test('should not navigate to change view if incorect reponse', done => {
+    sinon.stub(element.$.restAPI, 'applyFixSuggestion')
+        .returns(Promise.resolve({}));
+    sinon.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 => {
+    sinon.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'],
+              },
+            ],
+          },
+        }));
+    sinon.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 => {
+    sinon.stub(window, 'fetch').callsFake((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);
+    sinon.stub(GerritNav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+    element._handleApplyFix();
+    flush(() => {
+      assert.isFalse(GerritNav.navigateToChange.called);
+      assert.isTrue(errorStub.called);
+      done();
+    });
+  });
+});
+
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
deleted file mode 100644
index 77c72d4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock_test.js
+++ /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.
- */
-
-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.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
deleted file mode 100644
index 3f7da5a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ /dev/null
@@ -1,618 +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.
- */
-import '../../../scripts/bundled-polymer.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-comment-api_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {util} from '../../../scripts/util.js';
-
-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;
-  }
-
-  get comments() {
-    return this._comments;
-  }
-
-  get drafts() {
-    return this._drafts;
-  }
-
-  get robotComments() {
-    return this._robotComments;
-  }
-
-  /**
-   * Get an object mapping file paths to a boolean representing whether that
-   * path contains diff comments in the given patch set (including drafts and
-   * robot comments).
-   *
-   * Paths with comments are mapped to true, whereas paths without comments
-   * are not mapped.
-   *
-   * @param {Gerrit.PatchRange=} opt_patchRange The patch-range object containing
-   *     patchNum and basePatchNum properties to represent the range.
-   * @return {!Object}
-   */
-  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);
-          })) {
-          commentMap[path] = true;
-        }
-      }
-    }
-    return commentMap;
-  }
-
-  /**
-   * Gets all the comments and robot comments for the given change.
-   *
-   * @param {number=} opt_patchNum
-   * @return {!Object}
-   */
-  getAllPublishedComments(opt_patchNum) {
-    return this.getAllComments(false, opt_patchNum);
-  }
-
-  /**
-   * Gets all the comments for a particular thread group. Used for refreshing
-   * comments after the thread group has already been built.
-   *
-   * @param {string} rootId
-   * @return {!Array} an array of comments
-   */
-  getCommentsForThread(rootId) {
-    const allThreads = this.getAllThreadsForChange();
-    const threadMatch = allThreads.find(t => t.rootId === rootId);
-
-    // In the event that a single draft comment was removed by the thread-list
-    // 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
-   *
-   * @param {!Array} comments
-   * @param {boolean} parentOnly whether the only comments returned should have
-   *   the side attribute set to PARENT
-   * @param {string} commentSide whether the comment was left on the left or the
-   *   right side regardless or unified or side-by-side
-   * @param {number=} opt_line line number, can be undefined if file comment
-   * @return {!Array} an array of comments
-   */
-  _filterCommentsBySideAndLine(comments,
-      parentOnly, commentSide, opt_line) {
-    return comments.filter(c => {
-    // 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;
-      }
-      return sideMatch && c.line === opt_line;
-    }).map(c => {
-      c.__commentSide = commentSide;
-      return c;
-    });
-  }
-
-  /**
-   * Gets all the comments and robot comments for the given change.
-   *
-   * @param {boolean=} opt_includeDrafts
-   * @param {number=} opt_patchNum
-   * @return {!Object}
-   */
-  getAllComments(opt_includeDrafts,
-      opt_patchNum) {
-    const paths = this.getPaths();
-    const publishedComments = {};
-    for (const path of Object.keys(paths)) {
-      let commentsToAdd = this.getAllCommentsForPath(path, opt_patchNum);
-      if (opt_includeDrafts) {
-        const drafts = this.getAllDraftsForPath(path, opt_patchNum)
-            .map(d => Object.assign({__draft: true}, d));
-        commentsToAdd = commentsToAdd.concat(drafts);
-      }
-      publishedComments[path] = commentsToAdd;
-    }
-    return publishedComments;
-  }
-
-  /**
-   * Gets all the comments and robot comments for the given change.
-   *
-   * @param {number=} opt_patchNum
-   * @return {!Object}
-   */
-  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.
-   *
-   * @param {!string} path
-   * @param {number=} opt_patchNum
-   * @param {boolean=} opt_includeDrafts
-   * @return {!Array}
-   */
-  getAllCommentsForPath(path,
-      opt_patchNum, opt_includeDrafts) {
-    const comments = this._comments[path] || [];
-    const robotComments = this._robotComments[path] || [];
-    let allComments = comments.concat(robotComments);
-    if (opt_includeDrafts) {
-      const drafts = this.getAllDraftsForPath(path)
-          .map(d => Object.assign({__draft: true}, d));
-      allComments = allComments.concat(drafts);
-    }
-    if (!opt_patchNum) { return allComments; }
-    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.
-   *
-   * @param {!string} path
-   * @param {number=} opt_patchNum
-   * @return {!Array}
-   */
-  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
-   * 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.
-   *
-   * @param {!string} path
-   * @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}
-   */
-  getCommentsBySideForPath(path,
-      patchRange, opt_projectConfig) {
-    let comments = [];
-    let drafts = [];
-    let robotComments = [];
-    if (this.comments && this.comments[path]) {
-      comments = this.comments[path];
-    }
-    if (this.drafts && this.drafts[path]) {
-      drafts = this.drafts[path];
-    }
-    if (this.robotComments && this.robotComments[path]) {
-      robotComments = this.robotComments[path];
-    }
-
-    drafts.forEach(d => { d.__draft = true; });
-
-    const all = comments.concat(drafts).concat(robotComments);
-
-    const baseComments = all.filter(c =>
-      this._isInBaseOfPatchRange(c, patchRange));
-    const revisionComments = all.filter(c =>
-      this._isInRevisionOfPatchRange(c, patchRange));
-
-    return {
-      meta: {
-        changeNum: this._changeNum,
-        path,
-        patchRange,
-        projectConfig: opt_projectConfig,
-      },
-      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
-   *   of comments left on that file.
-   * @return {!Array} A flattened list of all comments, where each comment
-   *   also includes the file that it was left on, which was the key of the
-   *   originall object.
-   */
-  _commentObjToArrayWithFile(comments) {
-    let commentArr = [];
-    for (const file of Object.keys(comments)) {
-      const commentsForFile = [];
-      for (const comment of comments[file]) {
-        commentsForFile.push(Object.assign({__path: file}, comment));
-      }
-      commentArr = commentArr.concat(commentsForFile);
-    }
-    return commentArr;
-  }
-
-  _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.
-   *
-   * @param {{path: string, oldPath?: string, patchNum?: number}} file
-   * @return {number}
-   */
-  computeCommentCount(file) {
-    if (file.path) {
-      return this.getAllCommentsForFile(file).length;
-    }
-    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 {?{path: string, oldPath?: string, patchNum?: number}} file
-   * @return {number}
-   */
-  computeDraftCount(file) {
-    if (file && file.path) {
-      return this.getAllDraftsForFile(file).length;
-    }
-    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 {{path: string, oldPath?: string, patchNum?: number}} file
-   * @return {number}
-   */
-  computeUnresolvedNum(file) {
-    let comments = [];
-    let drafts = [];
-
-    if (file.path) {
-      comments = this.getAllCommentsForFile(file);
-      drafts = this.getAllDraftsForFile(file);
-    } else {
-      comments = this._commentObjToArray(
-          this.getAllPublishedComments(file.patchNum));
-    }
-
-    comments = comments.concat(drafts);
-
-    const threads = this.getCommentThreads(this._sortComments(comments));
-
-    const unresolvedThreads = threads
-        .filter(thread =>
-          thread.comments.length &&
-        thread.comments[thread.comments.length - 1].unresolved);
-
-    return unresolvedThreads.length;
-  }
-
-  getAllThreadsForChange() {
-    const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
-    const sortedComments = this._sortComments(comments);
-    return this.getCommentThreads(sortedComments);
-  }
-
-  _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.
-   *
-   * @param {!Array} comments sorted by updated timestamp.
-   * @return {!Array}
-   */
-  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 (comment.in_reply_to) {
-        const thread = idThreadMap[comment.in_reply_to];
-        if (thread) {
-          thread.comments.push(comment);
-          idThreadMap[comment.id] = thread;
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      const newThread = {
-        comments: [comment],
-        patchNum: comment.patch_set,
-        path: comment.__path,
-        line: comment.line,
-        rootId: comment.id,
-      };
-      if (comment.side) {
-        newThread.commentSide = comment.side;
-      }
-      threads.push(newThread);
-      idThreadMap[comment.id] = newThread;
-    }
-    return threads;
-  }
-
-  /**
-   * Whether the given comment should be included in the base side of the
-   * given patch range.
-   *
-   * @param {!Object} comment
-   * @param {!Gerrit.PatchRange} range
-   * @return {boolean}
-   */
-  _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);
-    }
-
-    // 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)) {
-      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)) {
-      return true;
-    }
-    return false;
-  }
-
-  /**
-   * Whether the given comment should be included in the revision side of the
-   * given patch range.
-   *
-   * @param {!Object} comment
-   * @param {!Gerrit.PatchRange} range
-   * @return {boolean}
-   */
-  _isInRevisionOfPatchRange(comment,
-      range) {
-    return comment.side !== PARENT &&
-      this._patchNumEquals(comment.patch_set, range.patchNum);
-  }
-
-  /**
-   * Whether the given comment should be included in the given patch range.
-   *
-   * @param {!Object} comment
-   * @param {!Gerrit.PatchRange} range
-   * @return {boolean|undefined}
-   */
-  _isInPatchRange(comment, range) {
-    return this._isInBaseOfPatchRange(comment, range) ||
-      this._isInRevisionOfPatchRange(comment, range);
-  }
-}
-
-/**
- * @extends Polymer.Element
- */
-class GrCommentApi extends mixinBehaviors( [
-  PatchSetBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-comment-api'; }
-
-  static get properties() {
-    return {
-      _changeComments: Object,
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('reload-drafts',
-        changeNum => this.reloadDrafts(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;
-    });
-  }
-
-  /**
-   * 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.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
new file mode 100644
index 0000000..2f6a1b4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -0,0 +1,670 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-comment-api_html';
+import {
+  getParentIndex,
+  isMergeParent,
+  patchNumEquals,
+} from '../../../utils/patch-set-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+  CommentBasics,
+  ConfigInfo,
+  ParentPatchSetNum,
+  PatchRange,
+  PatchSetNum,
+  PathToRobotCommentsInfoMap,
+  RobotCommentInfo,
+  UrlEncodedCommentId,
+  NumericChangeId,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {CommentSide} from '../../../constants/constants';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  Comment,
+  CommentMap,
+  CommentThread,
+  DraftInfo,
+  isUnresolved,
+  sortComments,
+  UIComment,
+  UIDraft,
+  UIHuman,
+  UIRobot,
+} from '../../../utils/comment-util';
+import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
+
+export type CommentIdToCommentThreadMap = {
+  [urlEncodedCommentId: string]: CommentThread;
+};
+
+export interface TwoSidesComments {
+  // TODO(TS): remove meta - it is not used anywhere
+  meta: {
+    changeNum: NumericChangeId;
+    path: string;
+    patchRange: PatchRange;
+    projectConfig?: ConfigInfo;
+  };
+  left: UIComment[];
+  right: UIComment[];
+}
+
+export class ChangeComments {
+  private readonly _comments: {[path: string]: UIHuman[]};
+
+  private readonly _robotComments: {[path: string]: UIRobot[]};
+
+  private readonly _drafts: {[path: string]: UIDraft[]};
+
+  private readonly _changeNum: NumericChangeId;
+
+  /**
+   * Construct a change comments object, which can be data-bound to child
+   * elements of that which uses the gr-comment-api.
+   */
+  constructor(
+    comments: {[path: string]: UIHuman[]} | undefined,
+    robotComments: {[path: string]: UIRobot[]} | undefined,
+    drafts: {[path: string]: UIDraft[]} | undefined,
+    changeNum: NumericChangeId
+  ) {
+    this._comments = this._addPath(comments);
+    this._robotComments = this._addPath(robotComments);
+    this._drafts = this._addPath(drafts);
+    // TODO(TS): remove changeNum param - it is not used anywhere
+    this._changeNum = changeNum;
+  }
+
+  /**
+   * Add path info to every comment as CommentInfo returned
+   * from server does not have that.
+   *
+   * TODO(taoalpha): should consider changing BE to send path
+   * back within CommentInfo
+   */
+  _addPath<T>(
+    comments: {[path: string]: T[]} = {}
+  ): {[path: string]: Array<T & {path: string}>} {
+    const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
+    for (const filePath of Object.keys(comments)) {
+      const allCommentsForPath = comments[filePath] || [];
+      if (allCommentsForPath.length) {
+        updatedComments[filePath] = allCommentsForPath.map(comment => {
+          return {...comment, path: filePath};
+        });
+      }
+    }
+    return updatedComments;
+  }
+
+  get comments() {
+    return this._comments;
+  }
+
+  get drafts() {
+    return this._drafts;
+  }
+
+  get robotComments() {
+    return this._robotComments;
+  }
+
+  findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
+    if (!commentId) return undefined;
+    const findComment = (comments: {[path: string]: UIComment[]}) => {
+      let comment;
+      for (const path of Object.keys(comments)) {
+        comment = comment || comments[path].find(c => c.id === commentId);
+      }
+      return comment;
+    };
+    return (
+      findComment(this._comments) ||
+      findComment(this._robotComments) ||
+      findComment(this._drafts)
+    );
+  }
+
+  /**
+   * Get an object mapping file paths to a boolean representing whether that
+   * path contains diff comments in the given patch set (including drafts and
+   * robot comments).
+   *
+   * Paths with comments are mapped to true, whereas paths without comments
+   * are not mapped.
+   *
+   * @param patchRange The patch-range object containing
+   * patchNum and basePatchNum properties to represent the range.
+   */
+  getPaths(patchRange?: PatchRange): CommentMap {
+    const responses: {[path: string]: UIComment[]}[] = [
+      this.comments,
+      this.drafts,
+      this.robotComments,
+    ];
+    const commentMap: CommentMap = {};
+    for (const response of responses) {
+      for (const path in response) {
+        if (
+          hasOwnProperty(response, path) &&
+          response[path].some(c => {
+            // If don't care about patch range, we know that the path exists.
+            if (!patchRange) {
+              return true;
+            }
+            return this._isInPatchRange(c, patchRange);
+          })
+        ) {
+          commentMap[path] = true;
+        }
+      }
+    }
+    return commentMap;
+  }
+
+  /**
+   * Gets all the comments and robot comments for the given change.
+   */
+  getAllPublishedComments(patchNum?: PatchSetNum) {
+    return this.getAllComments(false, patchNum);
+  }
+
+  /**
+   * Gets all the comments for a particular thread group. Used for refreshing
+   * comments after the thread group has already been built.
+   */
+  getCommentsForThread(rootId: UrlEncodedCommentId) {
+    const allThreads = this.getAllThreadsForChange();
+    const threadMatch = allThreads.find(t => t.rootId === rootId);
+
+    // In the event that a single draft comment was removed by the thread-list
+    // 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;
+  }
+
+  /**
+   * Gets all the comments and robot comments for the given change.
+   */
+  getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
+    const paths = this.getPaths();
+    const publishedComments: {[path: string]: CommentBasics[]} = {};
+    for (const path of Object.keys(paths)) {
+      publishedComments[path] = this.getAllCommentsForPath(
+        path,
+        patchNum,
+        includeDrafts
+      );
+    }
+    return publishedComments;
+  }
+
+  /**
+   * Gets all the drafts for the given change.
+   */
+  getAllDrafts(patchNum?: PatchSetNum) {
+    const paths = this.getPaths();
+    const drafts: {[path: string]: UIDraft[]} = {};
+    for (const path of Object.keys(paths)) {
+      drafts[path] = this.getAllDraftsForPath(path, patchNum);
+    }
+    return drafts;
+  }
+
+  /**
+   * Get the comments (robot comments) for a path and optional patch num.
+   *
+   * This method will always return a new shallow copy of all comments,
+   * so manipulation on one copy won't affect other copies.
+   *
+   */
+  getAllCommentsForPath(
+    path: string,
+    patchNum?: PatchSetNum,
+    includeDrafts?: boolean
+  ): Comment[] {
+    const comments: Comment[] = this._comments[path] || [];
+    const robotComments = this._robotComments[path] || [];
+    let allComments = comments.concat(robotComments);
+    if (includeDrafts) {
+      const drafts = this.getAllDraftsForPath(path);
+      allComments = allComments.concat(drafts);
+    }
+    if (patchNum) {
+      allComments = allComments.filter(c =>
+        patchNumEquals(c.patch_set, patchNum)
+      );
+    }
+    return allComments.map(c => {
+      return {...c};
+    });
+  }
+
+  /**
+   * Get the comments (robot comments) for a file.
+   *
+   * // TODO(taoalpha): maybe merge in *ForPath
+   */
+  getAllCommentsForFile(file: PatchSetFile, includeDrafts?: boolean) {
+    let allComments = this.getAllCommentsForPath(
+      file.path,
+      file.patchNum,
+      includeDrafts
+    );
+
+    if (file.basePath) {
+      allComments = allComments.concat(
+        this.getAllCommentsForPath(file.basePath, file.patchNum, includeDrafts)
+      );
+    }
+
+    return allComments;
+  }
+
+  /**
+   * Get the drafts for a path and optional patch num.
+   *
+   * This will return a shallow copy of all drafts every time,
+   * so changes on any copy will not affect other copies.
+   */
+  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
+    let comments = this._drafts[path] || [];
+    if (patchNum) {
+      comments = comments.filter(c => patchNumEquals(c.patch_set, patchNum));
+    }
+    return comments.map(c => {
+      return {...c, __draft: true};
+    });
+  }
+
+  /**
+   * Get the drafts for a file.
+   *
+   * // TODO(taoalpha): maybe merge in *ForPath
+   */
+  getAllDraftsForFile(file: PatchSetFile): Comment[] {
+    let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
+    if (file.basePath) {
+      allDrafts = allDrafts.concat(
+        this.getAllDraftsForPath(file.basePath, file.patchNum)
+      );
+    }
+    return allDrafts;
+  }
+
+  /**
+   * Get the comments (with drafts and robot comments) for a path 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.
+   *
+   * @param patchRange The patch-range object containing patchNum
+   * and basePatchNum properties to represent the range.
+   * @param projectConfig Optional project config object to
+   * include in the meta sub-object.
+   */
+  getCommentsBySideForPath(
+    path: string,
+    patchRange: PatchRange,
+    projectConfig?: ConfigInfo
+  ): TwoSidesComments {
+    let comments: Comment[] = [];
+    let drafts: DraftInfo[] = [];
+    let robotComments: RobotCommentInfo[] = [];
+    if (this.comments && this.comments[path]) {
+      comments = this.comments[path];
+    }
+    if (this.drafts && this.drafts[path]) {
+      drafts = this.drafts[path];
+    }
+    if (this.robotComments && this.robotComments[path]) {
+      robotComments = this.robotComments[path];
+    }
+
+    drafts.forEach(d => {
+      d.__draft = true;
+    });
+
+    const all: Comment[] = comments
+      .concat(drafts)
+      .concat(robotComments)
+      .map(c => {
+        return {...c};
+      });
+
+    const baseComments = all.filter(c =>
+      this._isInBaseOfPatchRange(c, patchRange)
+    );
+    const revisionComments = all.filter(c =>
+      this._isInRevisionOfPatchRange(c, patchRange)
+    );
+
+    return {
+      meta: {
+        changeNum: this._changeNum,
+        path,
+        patchRange,
+        projectConfig,
+      },
+      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 patchRange The patch-range object containing patchNum
+   * and basePatchNum properties to represent the range.
+   * @param projectConfig Optional project config object to
+   * include in the meta sub-object.
+   */
+  getCommentsBySideForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange,
+    projectConfig?: ConfigInfo
+  ): TwoSidesComments {
+    const comments = this.getCommentsBySideForPath(
+      file.path,
+      patchRange,
+      projectConfig
+    );
+    if (file.basePath) {
+      const commentsForBasePath = this.getCommentsBySideForPath(
+        file.basePath,
+        patchRange,
+        projectConfig
+      );
+      // merge in the left and right
+      comments.left = comments.left.concat(commentsForBasePath.left);
+      comments.right = comments.right.concat(commentsForBasePath.right);
+    }
+    return comments;
+  }
+
+  /**
+   * @param comments Object keyed by file, with a value of an array
+   * of comments left on that file.
+   * @return A flattened list of all comments, where each comment
+   * also includes the file that it was left on, which was the key of the
+   * originall object.
+   */
+  _commentObjToArrayWithFile<T>(comments: {
+    [path: string]: T[];
+  }): Array<T & {__path: string}> {
+    let commentArr: Array<T & {__path: string}> = [];
+    for (const file of Object.keys(comments)) {
+      const commentsForFile: Array<T & {__path: string}> = [];
+      for (const comment of comments[file]) {
+        commentsForFile.push({...comment, __path: file});
+      }
+      commentArr = commentArr.concat(commentsForFile);
+    }
+    return commentArr;
+  }
+
+  _commentObjToArray<T>(comments: {[path: string]: T[]}): T[] {
+    let commentArr: T[] = [];
+    for (const file of Object.keys(comments)) {
+      commentArr = commentArr.concat(comments[file]);
+    }
+    return commentArr;
+  }
+
+  /**
+   * Computes the number of comment threads in a given file or patch.
+   */
+  computeCommentThreadCount(file: PatchSetFile | PatchNumOnly) {
+    let comments: Comment[] = [];
+    if (isPatchSetFile(file)) {
+      comments = this.getAllCommentsForFile(file);
+    } else {
+      comments = this._commentObjToArray(
+        this.getAllPublishedComments(file.patchNum)
+      );
+    }
+
+    return this.getCommentThreads(comments).length;
+  }
+
+  /**
+   * Computes a string counting the number of draft comments in the entire
+   * change, optionally filtered by path and/or patchNum.
+   */
+  computeDraftCount(file?: PatchSetFile | PatchNumOnly) {
+    if (file && isPatchSetFile(file)) {
+      return this.getAllDraftsForFile(file).length;
+    }
+    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.
+   */
+  computeUnresolvedNum(file: PatchSetFile | PatchNumOnly) {
+    let comments: Comment[] = [];
+    let drafts: Comment[] = [];
+
+    if (isPatchSetFile(file)) {
+      comments = this.getAllCommentsForFile(file);
+      drafts = this.getAllDraftsForFile(file);
+    } else {
+      comments = this._commentObjToArray(
+        this.getAllPublishedComments(file.patchNum)
+      );
+    }
+
+    comments = comments.concat(drafts);
+    const threads = this.getCommentThreads(sortComments(comments));
+    const unresolvedThreads = threads.filter(isUnresolved);
+    return unresolvedThreads.length;
+  }
+
+  getAllThreadsForChange() {
+    const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
+    const sortedComments = sortComments(comments);
+    return this.getCommentThreads(sortedComments);
+  }
+
+  /**
+   * Computes all of the comments in thread format.
+   *
+   * @param comments sorted by updated timestamp.
+   */
+  getCommentThreads(comments: UIComment[]) {
+    const threads: CommentThread[] = [];
+    const idThreadMap: CommentIdToCommentThreadMap = {};
+    for (const comment of comments) {
+      if (!comment.id) continue;
+      // 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) {
+          thread.comments.push(comment);
+          idThreadMap[comment.id] = thread;
+          continue;
+        }
+      }
+
+      // Otherwise, this comment starts its own thread.
+      if (!comment.__path && !comment.path) {
+        throw new Error('Comment missing required "path".');
+      }
+      const newThread: CommentThread = {
+        comments: [comment],
+        patchNum: comment.patch_set,
+        path: comment.__path || comment.path!,
+        line: comment.line,
+        rootId: comment.id,
+      };
+      if (comment.side) {
+        newThread.commentSide = comment.side;
+      }
+      threads.push(newThread);
+      idThreadMap[comment.id] = newThread;
+    }
+    return threads;
+  }
+
+  /**
+   * Whether the given comment should be included in the base side of the
+   * given patch range.
+   */
+  _isInBaseOfPatchRange(comment: CommentBasics, range: PatchRange) {
+    // 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 === CommentSide.PARENT) {
+      return (
+        isMergeParent(range.basePatchNum) &&
+        comment.parent === getParentIndex(range.basePatchNum)
+      );
+    }
+
+    // If the base of the range is the parent of the patch:
+    if (
+      range.basePatchNum === ParentPatchSetNum &&
+      comment.side === CommentSide.PARENT &&
+      patchNumEquals(comment.patch_set, range.patchNum)
+    ) {
+      return true;
+    }
+    // If the base of the range is not the parent of the patch:
+    return (
+      range.basePatchNum !== ParentPatchSetNum &&
+      comment.side !== CommentSide.PARENT &&
+      patchNumEquals(comment.patch_set, range.basePatchNum)
+    );
+  }
+
+  /**
+   * Whether the given comment should be included in the revision side of the
+   * given patch range.
+   */
+  _isInRevisionOfPatchRange(comment: CommentBasics, range: PatchRange) {
+    return (
+      comment.side !== CommentSide.PARENT &&
+      patchNumEquals(comment.patch_set, range.patchNum)
+    );
+  }
+
+  /**
+   * Whether the given comment should be included in the given patch range.
+   */
+  _isInPatchRange(comment: CommentBasics, range: PatchRange): boolean {
+    return (
+      this._isInBaseOfPatchRange(comment, range) ||
+      this._isInRevisionOfPatchRange(comment, range)
+    );
+  }
+}
+
+// TODO(TS): move findCommentById out of class
+export const _testOnly_findCommentById =
+  ChangeComments.prototype.findCommentById;
+
+export interface GrCommentApi {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-comment-api')
+export class GrCommentApi extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  _changeComments?: ChangeComments;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('reload-drafts', changeNum =>
+      // TODO(TS): This is a wrong code, however keep it as is for now
+      // If changeNum param in ChangeComments is removed, this also must be
+      // removed
+      this.reloadDrafts((changeNum as unknown) as NumericChangeId)
+    );
+  }
+
+  /**
+   * 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.
+   */
+  loadAll(changeNum: NumericChangeId) {
+    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,
+        // TODO(TS): Promise.all somehow resolve all types to
+        // PathToCommentsInfoMap given its PathToRobotCommentsInfoMap
+        // returned from the second promise
+        robotComments as PathToRobotCommentsInfoMap,
+        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.
+   */
+  reloadDrafts(changeNum: NumericChangeId) {
+    if (!this._changeComments) {
+      return this.loadAll(changeNum);
+    }
+    const oldChangeComments = this._changeComments;
+    return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
+      this._changeComments = new ChangeComments(
+        oldChangeComments.comments,
+        (oldChangeComments.robotComments as unknown) as PathToRobotCommentsInfoMap,
+        drafts,
+        changeNum
+      );
+      return this._changeComments;
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-comment-api': 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
deleted file mode 100644
index 8aa0835..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
new file mode 100644
index 0000000..91d8b41
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 29262e3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
+++ /dev/null
@@ -1,755 +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">
-<meta charset="utf-8">
-<title>gr-comment-api</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-comment-api></gr-comment-api>
-  </template>
-</test-fixture>
-
-<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;
-
-  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(() => {
-      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({}));
-    });
-
-    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));
-
-      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(() => {
-        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('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({
-              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',
-                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: 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,
-                __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: 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: 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: 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: 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);
-
-        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(11),
-            expectedComments);
-
-        assert.deepEqual(element._changeComments.getCommentsForThread(1000),
-            null);
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
new file mode 100644
index 0000000..be6f646
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -0,0 +1,798 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-comment-api.js';
+import {ChangeComments} from './gr-comment-api.js';
+
+const basicFixture = fixtureFromElement('gr-comment-api');
+
+suite('gr-comment-api tests', () => {
+  const PARENT = 'PARENT';
+
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('loads logged-out', () => {
+    const changeNum = 1234;
+
+    sinon.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(false));
+    sinon.stub(element.$.restAPI, 'getDiffComments')
+        .returns(Promise.resolve({
+          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+        }));
+    sinon.stub(element.$.restAPI, 'getDiffRobotComments')
+        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    sinon.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;
+
+    sinon.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(true));
+    sinon.stub(element.$.restAPI, 'getDiffComments')
+        .returns(Promise.resolve({
+          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+        }));
+    sinon.stub(element.$.restAPI, 'getDiffRobotComments')
+        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    sinon.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(() => {
+      commentStub = sinon.stub(element.$.restAPI, 'getDiffComments')
+          .returns(Promise.resolve({}));
+      robotCommentStub = sinon.stub(element.$.restAPI,
+          'getDiffRobotComments').returns(Promise.resolve({}));
+      draftStub = sinon.stub(element.$.restAPI, 'getDiffDrafts')
+          .returns(Promise.resolve({}));
+    });
+
+    test('without loadAll first', done => {
+      assert.isNotOk(element._changeComments);
+      sinon.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));
+
+      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 = sinon.stub(element._changeComments,
+          '_isInBaseOfPatchRange');
+      const isInRevisionPatchStub = sinon.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(() => {
+        const drafts = {
+          'file/one': [
+            {
+              id: '12',
+              patch_set: 2,
+              side: PARENT,
+              line: 1,
+              updated: makeTime(3),
+            },
+            {
+              id: '13',
+              in_reply_to: '04',
+              patch_set: 2,
+              line: 1,
+              // Draft gets lower timestamp than published comment, because we
+              // want to test that the draft still gets sorted to the end.
+              updated: makeTime(2),
+            },
+          ],
+          'file/two': [
+            {
+              id: '05',
+              patch_set: 3,
+              line: 1,
+              updated: makeTime(3),
+            },
+          ],
+        };
+        const robotComments = {
+          'file/one': [
+            {
+              id: '01',
+              patch_set: 2,
+              side: PARENT,
+              line: 1,
+              updated: makeTime(1),
+              range: {
+                start_line: 1,
+                start_character: 2,
+                end_line: 2,
+                end_character: 2,
+              },
+            }, {
+              id: '02',
+              in_reply_to: '04',
+              patch_set: 2,
+              unresolved: true,
+              line: 1,
+              updated: makeTime(3),
+            },
+          ],
+        };
+        const comments = {
+          'file/one': [
+            {
+              id: '03',
+              patch_set: 2,
+              side: PARENT,
+              line: 2,
+              updated: makeTime(1),
+            },
+            {id: '04', patch_set: 2, line: 1, updated: makeTime(1)},
+          ],
+          'file/two': [
+            {id: '05', patch_set: 2, line: 2, updated: makeTime(1)},
+            {id: '06', patch_set: 3, line: 2, updated: makeTime(1)},
+          ],
+          'file/three': [
+            {
+              id: '07',
+              patch_set: 2,
+              side: PARENT,
+              unresolved: false,
+              line: 1,
+              updated: makeTime(1),
+            },
+            {
+              id: '08',
+              patch_set: 2,
+              side: PARENT,
+              unresolved: true,
+              in_reply_to: '07',
+              line: 1,
+              updated: makeTime(1),
+            },
+            {id: '09', patch_set: 3, line: 1, updated: makeTime(1)},
+          ],
+          'file/four': [
+            {
+              id: '10',
+              patch_set: 5,
+              side: PARENT,
+              line: 1,
+              updated: makeTime(1),
+            },
+            {id: '11', patch_set: 5, line: 1, updated: makeTime(1)},
+          ],
+        };
+        element._changeComments =
+            new ChangeComments(comments, robotComments, drafts, 1234);
+      });
+
+      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.equal(comments.length, 4);
+        path = 'file/two';
+        comments = element._changeComments.getAllCommentsForPath(path, 2);
+        assert.equal(comments.length, 1);
+        const aCopyOfComments = element._changeComments
+            .getAllCommentsForPath(path, 2);
+        assert.deepEqual(comments, aCopyOfComments);
+        assert.notEqual(comments[0], aCopyOfComments[0]);
+      });
+
+      test('getAllDraftsForPath', () => {
+        const path = 'file/one';
+        const drafts = element._changeComments.getAllDraftsForPath(path);
+        assert.equal(drafts.length, 2);
+        const aCopyOfDrafts = element._changeComments
+            .getAllDraftsForPath(path);
+        assert.deepEqual(drafts, aCopyOfDrafts);
+        assert.notEqual(drafts[0], aCopyOfDrafts[0]);
+      });
+
+      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', () => {
+        const 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,
+          }],
+        };
+        element._changeComments = new ChangeComments(comments, {}, {}, 1234);
+        assert.equal(
+            element._changeComments.computeUnresolvedNum(1, 'path'), 0);
+      });
+
+      test('computeCommentThreadCount', () => {
+        assert.equal(element._changeComments
+            .computeCommentThreadCount({
+              patchNum: 2,
+              path: 'file/one',
+            }), 3);
+        assert.equal(element._changeComments
+            .computeCommentThreadCount({
+              patchNum: 1,
+              path: 'file/one',
+            }), 0);
+        assert.equal(element._changeComments
+            .computeCommentThreadCount({
+              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: '01',
+                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',
+                __path: 'file/one',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+            rootId: '01',
+          }, {
+            comments: [
+              {
+                id: '03',
+                patch_set: 2,
+                side: 'PARENT',
+                line: 2,
+                path: 'file/one',
+                __path: 'file/one',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 2,
+            rootId: '03',
+          }, {
+            comments: [
+              {
+                id: '04',
+                patch_set: 2,
+                line: 1,
+                path: 'file/one',
+                __path: 'file/one',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+              {
+                id: '02',
+                in_reply_to: '04',
+                patch_set: 2,
+                unresolved: true,
+                line: 1,
+                path: 'file/one',
+                __path: 'file/one',
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+              {
+                id: '13',
+                in_reply_to: '04',
+                patch_set: 2,
+                line: 1,
+                path: 'file/one',
+                __path: 'file/one',
+                __draft: true,
+                updated: '2013-02-26 15:02:43.986000000',
+              },
+            ],
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+            rootId: '04',
+          }, {
+            comments: [
+              {
+                id: '05',
+                patch_set: 2,
+                line: 2,
+                path: 'file/two',
+                __path: 'file/two',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            patchNum: 2,
+            path: 'file/two',
+            line: 2,
+            rootId: '05',
+          }, {
+            comments: [
+              {
+                id: '06',
+                patch_set: 3,
+                line: 2,
+                path: 'file/two',
+                __path: 'file/two',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            patchNum: 3,
+            path: 'file/two',
+            line: 2,
+            rootId: '06',
+          }, {
+            comments: [
+              {
+                id: '07',
+                patch_set: 2,
+                side: 'PARENT',
+                unresolved: false,
+                line: 1,
+                path: 'file/three',
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+              {
+                id: '08',
+                in_reply_to: '07',
+                patch_set: 2,
+                side: 'PARENT',
+                unresolved: true,
+                line: 1,
+                path: 'file/three',
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/three',
+            line: 1,
+            rootId: '07',
+          }, {
+            comments: [
+              {
+                id: '09',
+                patch_set: 3,
+                line: 1,
+                path: 'file/three',
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            patchNum: 3,
+            path: 'file/three',
+            line: 1,
+            rootId: '09',
+          }, {
+            comments: [
+              {
+                id: '10',
+                patch_set: 5,
+                side: 'PARENT',
+                line: 1,
+                path: 'file/four',
+                __path: 'file/four',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 5,
+            path: 'file/four',
+            line: 1,
+            rootId: '10',
+          }, {
+            comments: [
+              {
+                id: '11',
+                patch_set: 5,
+                line: 1,
+                path: 'file/four',
+                __path: 'file/four',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            rootId: '11',
+            patchNum: 5,
+            path: 'file/four',
+            line: 1,
+          }, {
+            comments: [
+              {
+                id: '05',
+                patch_set: 3,
+                line: 1,
+                path: 'file/two',
+                __path: 'file/two',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+            ],
+            rootId: '05',
+            patchNum: 3,
+            path: 'file/two',
+            line: 1,
+          }, {
+            comments: [
+              {
+                id: '12',
+                patch_set: 2,
+                side: 'PARENT',
+                line: 1,
+                path: 'file/one',
+                __path: 'file/one',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+            ],
+            rootId: '12',
+            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',
+            path: 'file/one',
+            id: '04',
+            patch_set: 2,
+            line: 1,
+            updated: '2013-02-26 15:01:43.986000000',
+          },
+          {
+            __path: 'file/one',
+            path: 'file/one',
+            id: '02',
+            in_reply_to: '04',
+            patch_set: 2,
+            unresolved: true,
+            line: 1,
+            updated: '2013-02-26 15:03:43.986000000',
+          },
+          {
+            __path: 'file/one',
+            path: 'file/one',
+            __draft: true,
+            id: '13',
+            in_reply_to: '04',
+            patch_set: 2,
+            line: 1,
+            updated: '2013-02-26 15:02:43.986000000',
+          },
+        ];
+        assert.deepEqual(element._changeComments.getCommentsForThread('04'),
+            expectedComments);
+
+        expectedComments = [{
+          id: '12',
+          patch_set: 2,
+          side: 'PARENT',
+          line: 1,
+          path: 'file/one',
+          __path: 'file/one',
+          __draft: true,
+          updated: '2013-02-26 15:03:43.986000000',
+        }];
+
+        assert.deepEqual(element._changeComments.getCommentsForThread('12'),
+            expectedComments);
+
+        assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
+            null);
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index cdd6d8f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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-coverage-layer_html.js';
-import {CoverageType} from '../../../types/types.js';
-
-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.'],
-]);
-
-/** @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,
-
-      /**
-       * We keep track of the line number from the previous annotate() call,
-       * and also of the index of the coverage range that had matched.
-       * annotate() calls are coming in with increasing line numbers and
-       * coverage ranges are sorted by line number. So this is a very simple
-       * and efficient way for finding the coverage range that matches a given
-       * line number.
-       */
-      _lineNumber: {
-        type: Number,
-        value: 0,
-      },
-      _index: {
-        type: Number,
-        value: 0,
-      },
-    };
-  }
-
-  /**
-   * Layer method to add annotations to a line.
-   *
-   * @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;
-      }
-
-      // 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.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
new file mode 100644
index 0000000..6f9705f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -0,0 +1,124 @@
+/**
+ * @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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-coverage-layer_html';
+import {CoverageRange, CoverageType, DiffLayer} from '../../../types/types';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-coverage-layer': GrCoverageLayer;
+  }
+}
+
+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.'],
+]);
+
+@customElement('gr-coverage-layer')
+export class GrCoverageLayer
+  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  implements DiffLayer {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Must be sorted by code_range.start_line.
+   * Must only contain ranges that match the side.
+   */
+  @property({type: Array})
+  coverageRanges: CoverageRange[] = [];
+
+  @property({type: String})
+  side?: string;
+
+  /**
+   * We keep track of the line number from the previous annotate() call,
+   * and also of the index of the coverage range that had matched.
+   * annotate() calls are coming in with increasing line numbers and
+   * coverage ranges are sorted by line number. So this is a very simple
+   * and efficient way for finding the coverage range that matches a given
+   * line number.
+   */
+  @property({type: Number})
+  _lineNumber = 0;
+
+  @property({type: Number})
+  _index = 0;
+
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param _el Not used for this layer. (unused parameter)
+   * @param lineNumberEl The <td> element with the line number.
+   * @param line Not used for this layer.
+   */
+  annotate(_el: HTMLElement, lineNumberEl: HTMLElement) {
+    if (
+      !this.side ||
+      !lineNumberEl ||
+      !lineNumberEl.classList.contains(this.side)
+    ) {
+      return;
+    }
+    let elementLineNumber;
+    const dataValue = lineNumberEl.getAttribute('data-value');
+    if (dataValue) {
+      elementLineNumber = Number(dataValue);
+    }
+    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;
+      }
+
+      // The line number is within the current coverage range. Style it!
+      lineNumberEl.classList.add(coverageRange.type);
+      lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type) || '';
+      return;
+    }
+  }
+}
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
deleted file mode 100644
index 3ed33d1..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts
new file mode 100644
index 0000000..1489006
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts
@@ -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';
+
+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
deleted file mode 100644
index b80c56f3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
+++ /dev/null
@@ -1,138 +0,0 @@
-<!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-coverage-layer</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-coverage-layer></gr-coverage-layer>
-  </template>
-</test-fixture>
-
-<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,
-        },
-      },
-      {
-        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: 'NOT_INSTRUMENTED',
-        side: 'right',
-        code_range: {
-          start_line: 8,
-          end_line: 9,
-        },
-      },
-    ];
-
-    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');
-    });
-
-    test('line 3-4 are not covered', () => {
-      checkLine(3, 'NOT_COVERED');
-      checkLine(4, 'NOT_COVERED');
-    });
-
-    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');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js
new file mode 100644
index 0000000..e886e61
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-diff/gr-diff-line.js';
+import './gr-coverage-layer.js';
+
+const basicFixture = fixtureFromElement('gr-coverage-layer');
+
+suite('gr-coverage-layer', () => {
+  let element;
+
+  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: '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,
+        },
+      },
+    ];
+
+    element = basicFixture.instantiate();
+    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');
+    });
+
+    test('line 3-4 are not covered', () => {
+      checkLine(3, 'NOT_COVERED');
+      checkLine(4, 'NOT_COVERED');
+    });
+
+    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');
+    });
+  });
+});
+
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
deleted file mode 100644
index a65fdca..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ /dev/null
@@ -1,41 +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.
- */
-
-import {GrDiffBuilder} from './gr-diff-builder.js';
-
-/** @constructor */
-export function GrDiffBuilderBinary(diff, prefs, outputEl) {
-  GrDiffBuilder.call(this, diff, prefs, outputEl);
-}
-
-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) {};
-
-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-binary.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
new file mode 100644
index 0000000..7a26e77
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -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.
+ */
+
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+
+export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement
+  ) {
+    super(diff, prefs, outputEl);
+  }
+
+  buildSectionElement(): HTMLElement {
+    const section = this._createElement('tbody', 'binary-diff');
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
+    const fileRow = this._createRow(line);
+    const contentTd = fileRow.querySelector('td.both.file')!;
+    contentTd.textContent = ' Difference in binary files';
+    section.appendChild(fileRow);
+    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
deleted file mode 100644
index b38543c..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
+++ /dev/null
@@ -1,444 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
new file mode 100644
index 0000000..b464dce
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -0,0 +1,581 @@
+/**
+ * @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 '../gr-coverage-layer/gr-coverage-layer';
+import '../gr-diff-processor/gr-diff-processor';
+import '../../shared/gr-hovercard/gr-hovercard';
+import '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import './gr-diff-builder-side-by-side';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-builder-element_html';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {GrDiffBuilderImage} from './gr-diff-builder-image';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
+import {CancelablePromise, util} from '../../../scripts/util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+  BlameInfo,
+  DiffInfo,
+  DiffPreferencesInfo,
+  ImageInfo,
+} from '../../../types/common';
+import {CoverageRange, DiffLayer} from '../../../types/types';
+import {
+  GrDiffProcessor,
+  KeyLocations,
+} from '../gr-diff-processor/gr-diff-processor';
+import {
+  CommentRangeLayer,
+  GrRangedCommentLayer,
+} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {Side} from '../../../constants/constants';
+import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {getLineNumber} from '../gr-diff/gr-diff-utils';
+
+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;
+
+export interface GrDiffBuilderElement {
+  $: {
+    processor: GrDiffProcessor;
+    rangeLayer: GrRangedCommentLayer;
+    coverageLayerLeft: GrCoverageLayer;
+    coverageLayerRight: GrCoverageLayer;
+  };
+}
+
+export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+  return prefs.font_size * 4;
+}
+
+function annotateSymbols(
+  contentEl: HTMLElement,
+  line: GrDiffLine,
+  separator: string | RegExp,
+  className: string
+) {
+  const split = line.text.split(separator);
+  if (!split || split.length < 2) {
+    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 ${className}`
+    );
+
+    pos++;
+  }
+}
+
+@customElement('gr-diff-builder')
+export class GrDiffBuilderElement extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the diff begins rendering.
+   *
+   * @event render-start
+   */
+
+  /**
+   * Fired when the diff finishes rendering text content.
+   *
+   * @event render-content
+   */
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: String})
+  changeNum?: string;
+
+  @property({type: String})
+  patchNum?: string;
+
+  @property({type: String})
+  viewMode?: string;
+
+  @property({type: Boolean})
+  isImageDiff?: boolean;
+
+  @property({type: Object})
+  baseImage: ImageInfo | null = null;
+
+  @property({type: Object})
+  revisionImage: ImageInfo | null = null;
+
+  @property({type: Number})
+  parentIndex?: number;
+
+  @property({type: String})
+  path?: string;
+
+  @property({type: Object})
+  _builder?: GrDiffBuilder;
+
+  @property({type: Array})
+  _groups: GrDiffGroup[] = [];
+
+  /**
+   * Layers passed in from the outside.
+   */
+  @property({type: Array})
+  layers: DiffLayer[] = [];
+
+  /**
+   * All layers, both from the outside and the default ones.
+   */
+  @property({type: Array})
+  _layers: DiffLayer[] = [];
+
+  @property({type: Boolean})
+  _showTabs?: boolean;
+
+  @property({type: Boolean})
+  _showTrailingWhitespace?: boolean;
+
+  @property({type: Array})
+  commentRanges: CommentRangeLayer[] = [];
+
+  @property({type: Array})
+  coverageRanges: CoverageRange[] = [];
+
+  @property({type: Boolean})
+  useNewContextControls = false;
+
+  @property({
+    type: Array,
+    computed: '_computeLeftCoverageRanges(coverageRanges)',
+  })
+  _leftCoverageRanges?: CoverageRange[];
+
+  @property({
+    type: Array,
+    computed: '_computeRightCoverageRanges(coverageRanges)',
+  })
+  _rightCoverageRanges?: CoverageRange[];
+
+  /**
+   * The promise last returned from `render()` while the asynchronous
+   * rendering is running - `null` otherwise. Provides a `cancel()`
+   * method that rejects it with `{isCancelled: true}`.
+   */
+  @property({type: Object})
+  _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
+
+  /** @override */
+  detached() {
+    super.detached();
+    if (this._builder) {
+      this._builder.clear();
+    }
+  }
+
+  get diffElement() {
+    return this.queryEffectiveChildren('#diffTable') as HTMLTableElement;
+  }
+
+  _computeLeftCoverageRanges(coverageRanges: CoverageRange[]) {
+    return coverageRanges.filter(range => range && range.side === 'left');
+  }
+
+  _computeRightCoverageRanges(coverageRanges: CoverageRange[]) {
+    return coverageRanges.filter(range => range && range.side === 'right');
+  }
+
+  render(keyLocations: KeyLocations, prefs: DiffPreferencesInfo) {
+    // 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();
+
+    if (this._builder) {
+      this._builder.clear();
+    }
+    if (!this.diff) {
+      throw Error('Cannot render a diff without DiffInfo.');
+    }
+    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 as GrDiffBuilderImage).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);
+          return;
+        })
+    );
+  }
+
+  _setupAnnotationLayers() {
+    const layers: DiffLayer[] = [
+      this._createTrailingWhitespaceLayer(),
+      this._createIntralineLayer(),
+      this._createTabIndicatorLayer(),
+      this._createSpecialCharacterIndicatorLayer(),
+      this.$.rangeLayer,
+      this.$.coverageLayerLeft,
+      this.$.coverageLayerRight,
+    ];
+
+    if (this.layers) {
+      layers.push(...this.layers);
+    }
+    this._layers = layers;
+  }
+
+  getLineElByChild(node?: Node): HTMLElement | null {
+    while (node) {
+      if (node instanceof Element) {
+        if (node.classList.contains('lineNum')) {
+          return node as HTMLElement;
+        }
+        if (node.classList.contains('section')) {
+          return null;
+        }
+      }
+      node = node.previousSibling ?? node.parentElement ?? undefined;
+    }
+    return null;
+  }
+
+  getLineNumberByChild(node: Node) {
+    const lineEl = this.getLineElByChild(node);
+    return getLineNumber(lineEl);
+  }
+
+  getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
+    if (!this._builder) return null;
+    return this._builder.getContentTdByLine(lineNumber, side, root);
+  }
+
+  _getDiffRowByChild(child: Element) {
+    while (!child.classList.contains('diff-row') && child.parentElement) {
+      child = child.parentElement;
+    }
+    return child;
+  }
+
+  getContentTdByLineEl(lineEl?: Element): Element | null {
+    if (!lineEl) return null;
+    const line = getLineNumber(lineEl);
+    if (!line) return null;
+    const side = this.getSideByLineEl(lineEl);
+    // Performance optimization because we already have an element in the
+    // correct row
+    const row = this._getDiffRowByChild(lineEl);
+    return this.getContentTdByLine(line, side, row);
+  }
+
+  getLineElByNumber(lineNumber: string | number, side?: Side) {
+    const sideSelector = side ? '.' + side : '';
+    return this.diffElement.querySelector(
+      `.lineNum[data-value="${lineNumber}"]${sideSelector}`
+    );
+  }
+
+  getSideByLineEl(lineEl: Element) {
+    return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
+  }
+
+  emitGroup(group: GrDiffGroup, sectionEl: HTMLElement) {
+    if (!this._builder) return;
+    this._builder.emitGroup(group, sectionEl);
+  }
+
+  showContext(newGroups: GrDiffGroup[], sectionEl: HTMLElement) {
+    if (!this._builder) return;
+    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);
+    }
+    if (sectionEl.parentNode) {
+      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: string): never {
+    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: DiffInfo, prefs: DiffPreferencesInfo): GrDiffBuilder {
+    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+      this._handlePreferenceError('tab size');
+    }
+
+    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+      this._handlePreferenceError('diff width');
+    }
+
+    const localPrefs = {...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,
+        this.useNewContextControls
+      );
+    } else if (this.viewMode === DiffViewMode.UNIFIED) {
+      builder = new GrDiffBuilderUnified(
+        diff,
+        localPrefs,
+        this.diffElement,
+        this._layers,
+        this.useNewContextControls
+      );
+    }
+    if (!builder) {
+      throw Error(`Unsupported diff view mode: ${this.viewMode}`);
+    }
+    return builder;
+  }
+
+  _clearDiffContent() {
+    this.diffElement.innerHTML = '';
+  }
+
+  @observe('_groups.splices')
+  _groupsChanged(changeRecord: PolymerSpliceChange<GrDiffGroup[]>) {
+    if (!changeRecord || !this._builder) {
+      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, null);
+      }
+    }
+  }
+
+  _createIntralineLayer(): DiffLayer {
+    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: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        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(): DiffLayer {
+    const show = () => this._showTabs;
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        // If visible tabs are disabled, do nothing.
+        if (!show()) {
+          return;
+        }
+
+        // Find and annotate the locations of tabs.
+        annotateSymbols(contentEl, line, '\t', 'tab-indicator');
+      },
+    };
+  }
+
+  _createSpecialCharacterIndicatorLayer(): DiffLayer {
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        // Find and annotate the locations of soft hyphen (\u00AD)
+        annotateSymbols(contentEl, line, '\u00AD', 'special-char-indicator');
+        // Find and annotate Stateful Unicode directional controls
+        annotateSymbols(
+          contentEl,
+          line,
+          /[\u202A-\u202E\u2066-\u2069]/,
+          'special-char-warning'
+        );
+      },
+    };
+  }
+
+  _createTrailingWhitespaceLayer(): DiffLayer {
+    const show = () => {
+      return this._showTrailingWhitespace;
+    };
+
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        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: BlameInfo[] | null) {
+    if (!this._builder) return;
+    this._builder.setBlame(blame);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-builder': 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
deleted file mode 100644
index 4d6b890..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts
new file mode 100644
index 0000000..573f559
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts
@@ -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';
+
+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
deleted file mode 100644
index e5847c4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
+++ /dev/null
@@ -1,1233 +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">
-<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-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
new file mode 100644
index 0000000..b10b251
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -0,0 +1,1303 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-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/mocks/diff-response.js';
+import './gr-diff-builder-element.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
+import {GrDiffBuilder} from './gr-diff-builder.js';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+    <gr-diff-builder>
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+`);
+
+const divWithTextFixture = fixtureFromTemplate(html`
+<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+`);
+
+const mockDiffFixture = fixtureFromTemplate(html`
+<gr-diff-builder view-mode="SIDE_BY_SIDE">
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+`);
+
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
+
+suite('gr-diff-builder tests', () => {
+  let prefs;
+  let element;
+  let builder;
+
+  const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getProjectConfig() { return Promise.resolve({}); },
+    });
+    stubBaseUrl('/r');
+    prefs = {
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    builder = new GrDiffBuilder({content: []}, prefs);
+  });
+
+  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'));
+  });
+
+  suite('context control', () => {
+    function createContextGroups(options) {
+      const offset = options.offset || 0;
+      const numLines = options.count || 10;
+      const lines = [];
+      for (let i = 0; i < numLines; i++) {
+        const line = new GrDiffLine(GrDiffLineType.BOTH);
+        line.beforeNumber = offset + i + 1;
+        line.afterNumber = offset + i + 1;
+        line.text = 'lorem upsum';
+        lines.push(line);
+      }
+
+      return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
+    }
+
+    function createContextSectionForGroups(options) {
+      const section = document.createElement('div');
+      builder._createContextControls(
+          section, createContextGroups(options), DiffViewMode.UNIFIED);
+      return section;
+    }
+
+    suite('old style', () => {
+      setup(() => {
+        builder = new GrDiffBuilder(
+            {content: []}, prefs, null, [], false /* useNewContextControls */);
+      });
+
+      test('no +10 buttons for 10 or less lines', () => {
+        const section = createContextSectionForGroups({count: 10});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 1);
+        assert.equal(buttons[0].textContent, 'Show 10 common lines');
+      });
+
+      test('context control at the top', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 0, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 2);
+        assert.equal(buttons[0].textContent, 'Show 20 common lines');
+        assert.equal(buttons[1].textContent, '+10 below');
+      });
+
+      test('context control in the middle', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 10, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 3);
+        assert.equal(buttons[0].textContent, '+10 above');
+        assert.equal(buttons[1].textContent, 'Show 20 common lines');
+        assert.equal(buttons[2].textContent, '+10 below');
+      });
+
+      test('context control at the bottom', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 30, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 2);
+        assert.equal(buttons[0].textContent, '+10 above');
+        assert.equal(buttons[1].textContent, 'Show 20 common lines');
+      });
+    });
+
+    suite('new style', () => {
+      setup(() => {
+        builder = new GrDiffBuilder(
+            {content: []}, prefs, null, [], true /* useNewContextControls */);
+      });
+
+      test('no +10 buttons for 10 or less lines', () => {
+        const section = createContextSectionForGroups({count: 10});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 1);
+        assert.equal(buttons[0].textContent, '+10 common lines');
+      });
+
+      test('context control at the top', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 0, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 2);
+        assert.equal(buttons[0].textContent, '+20 common lines');
+        assert.equal(buttons[1].textContent, '+10');
+
+        assert.include([...buttons[0].classList.values()], 'belowButton');
+        assert.include([...buttons[1].classList.values()], 'belowButton');
+      });
+
+      test('context control in the middle', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 10, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 3);
+        assert.equal(buttons[0].textContent, '+20 common lines');
+        assert.equal(buttons[1].textContent, '+10');
+        assert.equal(buttons[2].textContent, '+10');
+
+        assert.include([...buttons[0].classList.values()], 'centeredButton');
+        assert.include([...buttons[1].classList.values()], 'aboveButton');
+        assert.include([...buttons[2].classList.values()], 'belowButton');
+      });
+
+      test('context control at the bottom', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 30, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 2);
+        assert.equal(buttons[0].textContent, '+20 common lines');
+        assert.equal(buttons[1].textContent, '+10');
+
+        assert.include([...buttons[0].classList.values()], 'aboveButton');
+        assert.include([...buttons[1].classList.values()], 'aboveButton');
+      });
+    });
+  });
+
+  test('newlines 1', () => {
+    let text = 'abcdef';
+
+    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);
+    // 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)[2], size);
+    }
+  });
+
+  test('_handlePreferenceError throws with invalid preference', () => {
+    const prefs = {tab_size: 0};
+    assert.throws(() => element._getDiffBuilder(element.diff, prefs));
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    element.addEventListener('show-alert', errorStub);
+    assert.throws(() => element._handlePreferenceError('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(GrDiffGroupType.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLineType.ADD));
+      }
+      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+    });
+
+    test('is total for remove', () => {
+      const group = new GrDiffGroup(GrDiffGroupType.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLineType.REMOVE));
+      }
+      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+    });
+
+    test('not total for empty', () => {
+      const group = new GrDiffGroup(GrDiffGroupType.BOTH);
+      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+    });
+
+    test('not total for non-delta', () => {
+      const group = new GrDiffGroup(GrDiffGroupType.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLineType.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 = divWithTextFixture.instantiate();
+      str = el.textContent;
+      annotateElementSpy = sinon.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 = basicFixture.instantiate();
+      element._showTabs = true;
+      layer = element._createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const line = {text: ''};
+      const el = document.createElement('div');
+      const annotateElementStub =
+          sinon.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 =
+          sinon.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 =
+          sinon.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 =
+          sinon.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 =
+          sinon.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 =
+          sinon.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 = basicFixture.instantiate();
+      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 = basicFixture.instantiate();
+        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 = basicFixture.instantiate();
+      element._showTrailingWhitespace = true;
+      layer = element._createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const line = {text: ''};
+      const el = document.createElement('div');
+      const annotateElementStub =
+          sinon.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 =
+          sinon.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 =
+          sinon.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 =
+          sinon.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 =
+          sinon.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 =
+          sinon.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 =
+          sinon.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 = basicFixture.instantiate();
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sinon.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 = basicFixture.instantiate();
+      outputEl = element.queryEffectiveChildren('#diffTable');
+      keyLocations = {left: {}, right: {}};
+      sinon.stub(element, '_getDiffBuilder').callsFake(() => {
+        const builder = new GrDiffBuilderSideBySide({content}, prefs, outputEl);
+        sinon.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 = sinon.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 = sinon.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 = mockDiffFixture.instantiate();
+      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('getContentTdByLineEl 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 contentTdLeft = diffRow.querySelectorAll('.content')[0];
+
+      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
+      const lineNumButtonRight = lineNumTdRight.querySelector('button');
+      const contentTdRight = diffRow.querySelectorAll('.content')[1];
+
+      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
+      assert.equal(
+          element.getContentTdByLineEl(lineNumButtonLeft), contentTdLeft);
+      assert.equal(
+          element.getContentTdByLineEl(lineNumTdRight), contentTdRight);
+      assert.equal(
+          element.getContentTdByLineEl(lineNumButtonRight), contentTdRight);
+    });
+
+    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 = sinon.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 = sinon.spy(builder, '_createTextEl');
+
+      sinon.stub(builder, '_getLineNumberEl').returns(
+          document.createElement('div')
+      );
+      sinon.stub(builder, 'findLinesByRange').callsFake(
+          (s, e, d, lines, elements) => {
+            // Add a line and a corresponding element.
+            lines.push(new GrDiffLine(GrDiffLineType.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(GrDiffLineType.BOTH));
+            lines.push(new GrDiffLine(GrDiffLineType.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 = sinon.stub(builder, '_getBlameByLineNum')
+          .returns(null);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('_getBlameCommitForBaseLine', () => {
+      sinon.stub(builder, '_getBlameByLineNum').returns(null);
+      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(GrDiffLineType.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder._createBlameCell(line.beforeNumber);
+
+      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');
+    });
+  });
+});
+
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
deleted file mode 100644
index 1fc0d4f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the 'License');
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an 'AS IS' BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {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)$/;
-
-/** @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;
-  }
-
-  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';
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
new file mode 100644
index 0000000..15264ea
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -0,0 +1,194 @@
+/**
+ * @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 {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {DiffInfo, DiffPreferencesInfo, ImageInfo} from '../../../types/common';
+import {GrEndpointParam} from '../../plugins/gr-endpoint-param/gr-endpoint-param';
+
+// MIME types for images we allow showing. Do not include SVG, it can contain
+// arbitrary JavaScript.
+const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
+
+export class GrDiffBuilderImage extends GrDiffBuilderSideBySide {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    private readonly _baseImage: ImageInfo | null,
+    private readonly _revisionImage: ImageInfo | null
+  ) {
+    super(diff, prefs, outputEl, []);
+  }
+
+  public renderDiff() {
+    const section = this._createElement('tbody', 'image-diff');
+
+    this._emitImagePair(section);
+    this._emitImageLabels(section);
+
+    this._outputEl.appendChild(section);
+    this._outputEl.appendChild(this._createEndpoint());
+  }
+
+  private _createEndpoint() {
+    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 endpointDomApi = this._createElement('gr-endpoint-decorator');
+    endpointDomApi.setAttribute('name', 'image-diff');
+    endpointDomApi.appendChild(
+      this._createEndpointParam('baseImage', this._baseImage)
+    );
+    endpointDomApi.appendChild(
+      this._createEndpointParam('revisionImage', this._revisionImage)
+    );
+    td.appendChild(endpointDomApi);
+    tr.appendChild(td);
+    tbody.appendChild(tr);
+    return tbody;
+  }
+
+  private _createEndpointParam(name: string, value: ImageInfo | null) {
+    const endpointParam = this._createElement(
+      'gr-endpoint-param'
+    ) as GrEndpointParam;
+    endpointParam.name = name;
+    endpointParam.value = value;
+    return endpointParam;
+  }
+
+  private _emitImagePair(section: HTMLElement) {
+    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);
+  }
+
+  private _createImageCell(
+    image: ImageInfo | null,
+    className: string,
+    section: HTMLElement
+  ) {
+    const td = this._createElement('td', className);
+    if (image && IMAGE_MIME_PATTERN.test(image.type)) {
+      const imageEl = this._createElement('img') as HTMLImageElement;
+      imageEl.onload = () => {
+        image._height = imageEl.naturalHeight;
+        image._width = imageEl.naturalWidth;
+        this._updateImageLabel(section, className, image);
+      };
+      imageEl.setAttribute('src', `data:${image.type};base64, ${image.body}`);
+      imageEl.addEventListener('error', (e: Event) => {
+        imageEl.remove();
+        td.textContent = '[Image failed to load] ' + e.type;
+      });
+      td.appendChild(imageEl);
+    }
+    return td;
+  }
+
+  private _updateImageLabel(
+    section: HTMLElement,
+    className: string,
+    image: ImageInfo
+  ) {
+    const label = section.querySelector(
+      '.' + className + ' span.label'
+    ) as HTMLElement;
+    this._setLabelText(label, image);
+  }
+
+  private _setLabelText(label: HTMLElement, image: ImageInfo | null) {
+    label.textContent = _getImageLabel(image);
+  }
+
+  private _emitImageLabels(section: HTMLElement) {
+    const tr = 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);
+
+    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);
+
+    label.appendChild(labelSpan);
+    td.appendChild(label);
+    tr.appendChild(td);
+
+    section.appendChild(tr);
+  }
+}
+
+function _getImageLabel(image: ImageInfo | null) {
+  if (image) {
+    const type = image.type ?? image._expectedType;
+    if (image._width && image._height) {
+      return `${image._width}×${image._height} ${type}`;
+    } else {
+      return type;
+    }
+  }
+  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
deleted file mode 100644
index 8b73936..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the 'License');
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an 'AS IS' BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {GrDiffBuilder} from './gr-diff-builder.js';
-
-/** @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');
-  }
-  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');
-
-  // 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 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 content.
-  colgroup.appendChild(document.createElement('col'));
-
-  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;
-
-  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;
-};
-
-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._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-side-by-side.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
new file mode 100644
index 0000000..657dfa2
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -0,0 +1,138 @@
+/**
+ * @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 {GrDiffBuilder} from './gr-diff-builder';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
+import {DiffViewMode, Side} from '../../../constants/constants';
+
+export class GrDiffBuilderSideBySide extends GrDiffBuilder {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    // TODO(TS): Replace any by a layer interface.
+    readonly layers: any[] = [],
+    useNewContextControls = false
+  ) {
+    super(diff, prefs, outputEl, layers, useNewContextControls);
+  }
+
+  _getMoveControlsConfig() {
+    return {
+      numberOfCells: 4,
+      movedOutIndex: 1,
+      movedInIndex: 3,
+    };
+  }
+
+  buildSectionElement(group: GrDiffGroup) {
+    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.dueToMove) {
+      sectionEl.classList.add('dueToMove');
+      sectionEl.appendChild(this._buildMoveControls(group));
+    }
+    if (group.ignoredWhitespaceOnly) {
+      sectionEl.classList.add('ignoredWhitespaceOnly');
+    }
+    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
+      this._createContextControls(
+        sectionEl,
+        group.contextGroups,
+        DiffViewMode.SIDE_BY_SIDE
+      );
+      return sectionEl;
+    }
+
+    const pairs = group.getSideBySidePairs();
+    for (let i = 0; i < pairs.length; i++) {
+      sectionEl.appendChild(this._createRow(pairs[i].left, pairs[i].right));
+    }
+    return sectionEl;
+  }
+
+  addColumns(outputEl: HTMLElement, fontSize: number): void {
+    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.toString());
+    colgroup.appendChild(col);
+
+    // Add left-side content.
+    colgroup.appendChild(document.createElement('col'));
+
+    // Add right-side line number.
+    col = document.createElement('col');
+    col.setAttribute('width', width.toString());
+    colgroup.appendChild(col);
+
+    // Add right-side content.
+    colgroup.appendChild(document.createElement('col'));
+
+    outputEl.appendChild(colgroup);
+  }
+
+  _createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
+    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.beforeNumber));
+
+    this._appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
+    this._appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
+    return row;
+  }
+
+  _appendPair(
+    row: HTMLElement,
+    line: GrDiffLine,
+    lineNumber: LineNumber,
+    side: Side
+  ) {
+    const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
+    row.appendChild(lineNumberEl);
+    row.appendChild(this._createTextEl(lineNumberEl, line, side));
+  }
+
+  _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
+    let tr: HTMLElement = content.parentElement!.parentElement!;
+    while ((tr = tr.nextSibling as HTMLElement)) {
+      const nextContent = tr.querySelector(
+        'td.content .contentText[data-side="' + side + '"]'
+      );
+      if (nextContent) return nextContent as HTMLElement;
+    }
+    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
deleted file mode 100644
index 8163176..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the 'License');
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an 'AS IS' BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffBuilder} from './gr-diff-builder.js';
-
-export function GrDiffBuilderUnified(diff, prefs, outputEl, layers) {
-  GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
-}
-GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
-GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
-
-GrDiffBuilderUnified.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');
-  }
-
-  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, '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');
-    }
-  }
-  return null;
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
new file mode 100644
index 0000000..2028b0c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -0,0 +1,140 @@
+/**
+ * @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 {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {DiffViewMode, Side} from '../../../constants/constants';
+
+export class GrDiffBuilderUnified extends GrDiffBuilder {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    // TODO(TS): Replace any by a layer interface.
+    readonly layers: any[] = [],
+    useNewContextControls = false
+  ) {
+    super(diff, prefs, outputEl, layers, useNewContextControls);
+  }
+
+  _getMoveControlsConfig() {
+    return {
+      numberOfCells: 3,
+      movedOutIndex: 2,
+      movedInIndex: 2,
+    };
+  }
+
+  buildSectionElement(group: GrDiffGroup): HTMLElement {
+    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.dueToMove) {
+      sectionEl.classList.add('dueToMove');
+      sectionEl.appendChild(this._buildMoveControls(group));
+    }
+    if (group.ignoredWhitespaceOnly) {
+      sectionEl.classList.add('ignoredWhitespaceOnly');
+    }
+    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
+      this._createContextControls(
+        sectionEl,
+        group.contextGroups,
+        DiffViewMode.UNIFIED
+      );
+      return sectionEl;
+    }
+
+    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 === GrDiffLineType.REMOVE) {
+        continue;
+      }
+      sectionEl.appendChild(this._createRow(line));
+    }
+    return sectionEl;
+  }
+
+  addColumns(outputEl: HTMLElement, fontSize: number): void {
+    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.toString());
+    colgroup.appendChild(col);
+
+    // Add right-side line number.
+    col = document.createElement('col');
+    col.setAttribute('width', width.toString());
+    colgroup.appendChild(col);
+
+    // Add the content.
+    colgroup.appendChild(document.createElement('col'));
+
+    outputEl.appendChild(colgroup);
+  }
+
+  _createRow(line: GrDiffLine) {
+    const row = this._createElement('tr', line.type);
+    row.classList.add('diff-row', 'unified');
+    row.tabIndex = -1;
+    row.appendChild(this._createBlameCell(line.beforeNumber));
+    let lineNumberEl = this._createLineEl(
+      line,
+      line.beforeNumber,
+      GrDiffLineType.REMOVE,
+      Side.LEFT
+    );
+    row.appendChild(lineNumberEl);
+    lineNumberEl = this._createLineEl(
+      line,
+      line.afterNumber,
+      GrDiffLineType.ADD,
+      Side.RIGHT
+    );
+    row.appendChild(lineNumberEl);
+    row.appendChild(this._createTextEl(lineNumberEl, line));
+    return row;
+  }
+
+  _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
+    let tr: HTMLElement = content.parentElement!.parentElement!;
+    while ((tr = tr.nextSibling as HTMLElement)) {
+      if (
+        tr.classList.contains('both') ||
+        (side === 'left' && tr.classList.contains('remove')) ||
+        (side === 'right' && tr.classList.contains('add'))
+      ) {
+        return tr.querySelector('.contentText');
+      }
+    }
+    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
deleted file mode 100644
index 2d26667..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
+++ /dev/null
@@ -1,205 +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">
-<meta charset="utf-8">
-<title>GrDiffBuilderUnified</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 '../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';
-
-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);
-    });
-
-    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);
-    });
-  });
-
-  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-unified_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
new file mode 100644
index 0000000..07c6410
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
@@ -0,0 +1,242 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-diff/gr-diff-group.js';
+import './gr-diff-builder.js';
+import './gr-diff-builder-unified.js';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
+
+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(GrDiffLineType.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup(GrDiffGroupType.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);
+    });
+  });
+
+  suite('buildSectionElement for moved chunks', () => {
+    test('creates a moved out group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 15),
+        new GrDiffLine(GrDiffLineType.REMOVE, 16),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
+      group.dueToMove = true;
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveDescription'));
+      assert.equal(cells[2].textContent, 'Moved out');
+    });
+
+    test('creates a moved in group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.ADD, 37),
+        new GrDiffLine(GrDiffLineType.ADD, 38),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
+      group.dueToMove = true;
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveDescription'));
+      assert.equal(cells[2].textContent, 'Moved in');
+    });
+  });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines;
+    let group;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 1),
+        new GrDiffLine(GrDiffLineType.REMOVE, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 2),
+        new GrDiffLine(GrDiffLineType.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(GrDiffGroupType.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);
+    });
+  });
+});
+
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
deleted file mode 100644
index 42fd6ea..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ /dev/null
@@ -1,637 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the 'License');
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an 'AS IS' BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {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';
-
-/**
- * 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;
-
-  this.layers = layers || [];
-
-  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 (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 => 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`);
-      }
-    }
-  }
-
-  return td;
-};
-
-GrDiffBuilder.prototype._decorateLineEl = function(el, number, side) {
-  el.classList.add(side);
-  el.dataset.value = number;
-};
-
-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) {
-    if (typeof layer.annotate == 'function') {
-      layer.annotate(contentText, lineNumberEl, line);
-    }
-  }
-
-  td.appendChild(contentText);
-
-  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');
-
-  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;
-          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;
-      }
-    }
-  }
-  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;
-};
-
-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;
-};
-
-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');
-};
-
-/**
- * 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;
-
-  // 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}"]`);
-};
-
-/**
- * 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;
-      }
-    }
-  }
-  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 date = (new Date(commit.time * 1000)).toLocaleDateString();
-  const blameNode = this._createElement('span',
-      isStartOfRange ? 'startOfRange' : '');
-
-  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;
-};
-
-/**
- * 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.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
new file mode 100644
index 0000000..a3778ef
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -0,0 +1,1035 @@
+/**
+ * @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 {getBaseUrl} from '../../../utils/url-util';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupRange,
+  GrDiffGroupType,
+  hideInContextControl,
+  rangeBySide,
+} from '../gr-diff/gr-diff-group';
+import {BlameInfo, DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+
+/**
+ * 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]/;
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+enum ContextButtonType {
+  ABOVE = 'above',
+  BELOW = 'below',
+  ALL = 'all',
+}
+
+export interface ContextEvent extends Event {
+  detail: {
+    groups: GrDiffGroup[];
+    section: HTMLElement;
+    numLines: number;
+  };
+}
+
+export interface ContentLoadNeededEventDetail {
+  lineRange: GrDiffGroupRange;
+}
+
+export abstract class GrDiffBuilder {
+  private readonly _diff: DiffInfo;
+
+  private readonly _numLinesLeft: number;
+
+  private readonly _prefs: DiffPreferencesInfo;
+
+  protected readonly _outputEl: HTMLElement;
+
+  readonly groups: GrDiffGroup[];
+
+  private _blameInfo: BlameInfo[] | null;
+
+  private readonly _layerUpdateListener: (
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) => void;
+
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    readonly layers: DiffLayer[] = [],
+    protected readonly useNewContextControls: boolean = false
+  ) {
+    this._diff = diff;
+    this._numLinesLeft = this._diff.content
+      ? this._diff.content.reduce((sum, chunk) => {
+          const left = chunk.a || chunk.ab;
+          return sum + (left?.length || chunk.skip || 0);
+        }, 0)
+      : 0;
+    this._prefs = prefs;
+    this._outputEl = outputEl;
+    this.groups = [];
+    this._blameInfo = null;
+
+    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.');
+    }
+
+    this._layerUpdateListener = (
+      start: LineNumber,
+      end: LineNumber,
+      side: Side
+    ) => this._handleLayerUpdate(start, end, side);
+    for (const layer of this.layers) {
+      if (layer.addListener) {
+        layer.addListener(this._layerUpdateListener);
+      }
+    }
+  }
+
+  clear() {
+    for (const layer of this.layers) {
+      if (layer.removeListener) {
+        layer.removeListener(this._layerUpdateListener);
+      }
+    }
+  }
+
+  // TODO(TS): Convert to enum.
+  static readonly GroupType = {
+    ADDED: 'b',
+    BOTH: 'ab',
+    REMOVED: 'a',
+  };
+
+  // TODO(TS): Convert to enum.
+  static readonly Highlights = {
+    ADDED: 'edit_b',
+    REMOVED: 'edit_a',
+  };
+
+  // TODO(TS): Replace usages with ContextButtonType enum.
+  static readonly ContextButtonType = {
+    ABOVE: 'above',
+    BELOW: 'below',
+    ALL: 'all',
+  };
+
+  abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
+
+  abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
+
+  emitGroup(group: GrDiffGroup, beforeSection: HTMLElement | null) {
+    const element = this.buildSectionElement(group);
+    this._outputEl.insertBefore(element, beforeSection);
+    group.element = element;
+  }
+
+  getGroupsByLineRange(
+    startLine: LineNumber,
+    endLine: LineNumber,
+    side?: 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 (side) {
+        const range = rangeBySide(group.lineRange, side);
+        groupStartLine = range.start || 0;
+        groupEndLine = range.end || 0;
+      }
+
+      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;
+  }
+
+  getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root: Element = this._outputEl
+  ): Element | null {
+    const sideSelector: string = side ? `.${side}` : '';
+    return root.querySelector(
+      `td.lineNum[data-value="${lineNumber}"]${sideSelector} ~ td.content`
+    );
+  }
+
+  getContentByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: HTMLElement
+  ): HTMLElement | null {
+    const td = this.getContentTdByLine(lineNumber, side, root);
+    return td ? td.querySelector('.contentText') : null;
+  }
+
+  /**
+   * Find line elements or line objects by a range of line numbers and a side.
+   *
+   * @param start The first line number
+   * @param end The last line number
+   * @param side The side of the range. Either 'left' or 'right'.
+   * @param out_lines The output list of line objects. Use null if not desired.
+   * @param out_elements The output list of line elements. Use null if not
+   *        desired.
+   */
+  findLinesByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side,
+    out_lines: GrDiffLine[] | null,
+    out_elements: HTMLElement[] | null
+  ) {
+    const groups = this.getGroupsByLineRange(start, end, side);
+    for (const group of groups) {
+      let content: HTMLElement | null = null;
+      for (const line of group.lines) {
+        if (
+          (side === 'left' && line.type === GrDiffLineType.ADD) ||
+          (side === 'right' && line.type === GrDiffLineType.REMOVE)
+        ) {
+          continue;
+        }
+        const lineNumber =
+          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, side);
+          } else {
+            content = this.getContentByLine(lineNumber, side, group.element);
+          }
+          if (content) {
+            out_elements.push(content);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Re-renders the DIV.contentText elements for the given side and range of
+   * diff content.
+   */
+  _renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
+    const lines: GrDiffLine[] = [];
+    const elements: HTMLElement[] = [];
+    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 || !el.parentElement) {
+        // 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
+      );
+    }
+  }
+
+  getSectionsByLineRange(
+    startLine: LineNumber,
+    endLine: LineNumber,
+    side: Side
+  ) {
+    return this.getGroupsByLineRange(startLine, endLine, side).map(
+      group => group.element
+    );
+  }
+
+  _createContextControls(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    viewMode: DiffViewMode
+  ) {
+    const leftStart = contextGroups[0].lineRange.left.start!;
+    const leftEnd = contextGroups[contextGroups.length - 1].lineRange.left.end!;
+    const numLines = leftEnd - leftStart + 1;
+
+    if (numLines === 0) console.error('context group without lines');
+
+    const firstGroupIsSkipped = !!contextGroups[0].skip;
+    const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
+
+    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+    const showAbove = leftStart > 1 && !firstGroupIsSkipped;
+    const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
+
+    if (this.useNewContextControls) {
+      section.classList.add('newStyle');
+      if (showAbove) {
+        const paddingRow = this._createContextControlPaddingRow(viewMode);
+        paddingRow.classList.add('above');
+        section.appendChild(paddingRow);
+      }
+      section.appendChild(
+        this._createNewContextControlRow(
+          section,
+          contextGroups,
+          showAbove,
+          showBelow,
+          numLines
+        )
+      );
+      if (showBelow) {
+        const paddingRow = this._createContextControlPaddingRow(viewMode);
+        paddingRow.classList.add('below');
+        section.appendChild(paddingRow);
+      }
+    } else {
+      section.appendChild(
+        this._createOldContextControlRow(
+          section,
+          contextGroups,
+          viewMode,
+          showAbove && showPartialLinks,
+          showBelow && showPartialLinks,
+          numLines
+        )
+      );
+    }
+  }
+
+  /**
+   * Creates old-style context controls: a single row of "+X above" and
+   * "+X below" buttons.
+   */
+  _createOldContextControlRow(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    viewMode: DiffViewMode,
+    showAbove: boolean,
+    showBelow: boolean,
+    numLines: number
+  ) {
+    const row = this._createElement('tr', GrDiffGroupType.CONTEXT_CONTROL);
+
+    row.classList.add('diff-row');
+    row.classList.add(
+      viewMode === DiffViewMode.SIDE_BY_SIDE ? 'side-by-side' : 'unified'
+    );
+
+    row.tabIndex = -1;
+    row.appendChild(this._createBlameCell(0));
+    row.appendChild(this._createElement('td', 'contextLineNum'));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(
+        this._createOldContextControlButtons(
+          section,
+          contextGroups,
+          showAbove,
+          showBelow,
+          numLines
+        )
+      );
+    }
+    row.appendChild(this._createElement('td', 'contextLineNum'));
+    row.appendChild(
+      this._createOldContextControlButtons(
+        section,
+        contextGroups,
+        showAbove,
+        showBelow,
+        numLines
+      )
+    );
+
+    return row;
+  }
+
+  _createOldContextControlButtons(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    showAbove: boolean,
+    showBelow: boolean,
+    numLines: number
+  ): HTMLElement {
+    const td = this._createElement('td');
+
+    if (showAbove) {
+      td.appendChild(
+        this._createContextButton(
+          ContextButtonType.ABOVE,
+          section,
+          contextGroups,
+          numLines
+        )
+      );
+    }
+
+    td.appendChild(
+      this._createContextButton(
+        ContextButtonType.ALL,
+        section,
+        contextGroups,
+        numLines
+      )
+    );
+
+    if (showBelow) {
+      td.appendChild(
+        this._createContextButton(
+          ContextButtonType.BELOW,
+          section,
+          contextGroups,
+          numLines
+        )
+      );
+    }
+
+    return td;
+  }
+
+  /**
+   * Creates new-style context controls: buttons extend from the gap created by
+   * this method up or down into the area of code that they affect.
+   */
+  _createNewContextControlRow(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    showAbove: boolean,
+    showBelow: boolean,
+    numLines: number
+  ): HTMLElement {
+    const row = this._createElement('tr', 'contextDivider');
+    if (!(showAbove && showBelow)) {
+      row.classList.add('collapsed');
+    }
+
+    const element = this._createElement('td', 'dividerCell');
+    row.appendChild(element);
+
+    const showAllContainer = this._createElement('div', 'aboveBelowButtons');
+    element.appendChild(showAllContainer);
+
+    const showAllButton = this._createContextButton(
+      ContextButtonType.ALL,
+      section,
+      contextGroups,
+      numLines
+    );
+    showAllButton.classList.add(
+      showAbove && showBelow
+        ? 'centeredButton'
+        : showAbove
+        ? 'aboveButton'
+        : 'belowButton'
+    );
+    showAllContainer.appendChild(showAllButton);
+
+    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+    if (showPartialLinks) {
+      const container = this._createElement('div', 'aboveBelowButtons');
+      if (showAbove) {
+        container.appendChild(
+          this._createContextButton(
+            ContextButtonType.ABOVE,
+            section,
+            contextGroups,
+            numLines
+          )
+        );
+      }
+      if (showBelow) {
+        container.appendChild(
+          this._createContextButton(
+            ContextButtonType.BELOW,
+            section,
+            contextGroups,
+            numLines
+          )
+        );
+      }
+      element.appendChild(container);
+    }
+
+    return row;
+  }
+
+  /**
+   * Creates a table row to serve as padding between code and context controls.
+   * Blame column, line gutters, and content area will continue visually, but
+   * context controls can render over this background to map more clearly to
+   * the area of code they expand.
+   */
+  _createContextControlPaddingRow(viewMode: DiffViewMode) {
+    const row = this._createElement('tr', 'contextBackground');
+
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.classList.add('side-by-side');
+      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
+      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
+    } else {
+      row.classList.add('unified');
+    }
+
+    row.appendChild(this._createBlameCell(0));
+    row.appendChild(this._createElement('td', 'contextLineNum'));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(this._createElement('td'));
+    }
+    row.appendChild(this._createElement('td', 'contextLineNum'));
+    row.appendChild(this._createElement('td'));
+
+    return row;
+  }
+
+  _createContextButton(
+    type: ContextButtonType,
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    numLines: number
+  ) {
+    const context = PARTIAL_CONTEXT_AMOUNT;
+    const button = this._createElement('gr-button', 'showContext');
+    if (this.useNewContextControls) {
+      button.classList.add('contextControlButton');
+    }
+    button.setAttribute('link', 'true');
+    button.setAttribute('no-uppercase', 'true');
+
+    let text = '';
+    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+    let requiresLoad = false;
+    if (type === GrDiffBuilder.ContextButtonType.ALL) {
+      if (this.useNewContextControls) {
+        text = `+${numLines} common line`;
+      } else {
+        text = `Show ${numLines} common line`;
+        const icon = this._createElement('iron-icon', 'showContext');
+        icon.setAttribute('icon', 'gr-icons:unfold-more');
+        button.appendChild(icon);
+      }
+      if (numLines > 1) {
+        text += 's';
+      }
+      requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
+      if (requiresLoad) {
+        // Expanding content would require load of more data
+        text += ' (too large)';
+      }
+      groups.push(...contextGroups);
+    } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
+      groups = hideInContextControl(contextGroups, context, numLines);
+      if (this.useNewContextControls) {
+        text = `+${context}`;
+        button.classList.add('aboveButton');
+      } else {
+        text = `+${context} above`;
+      }
+    } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
+      groups = hideInContextControl(contextGroups, 0, numLines - context);
+      if (this.useNewContextControls) {
+        text = `+${context}`;
+        button.classList.add('belowButton');
+      } else {
+        text = `+${context} below`;
+      }
+    }
+    const textSpan = this._createElement('span', 'showContext');
+    textSpan.textContent = text;
+    button.appendChild(textSpan);
+
+    if (requiresLoad) {
+      button.addEventListener('tap', e => {
+        e.stopPropagation();
+        const firstRange = groups[0].lineRange;
+        const lastRange = groups[groups.length - 1].lineRange;
+        const lineRange = {
+          left: {start: firstRange.left.start, end: lastRange.left.end},
+          right: {start: firstRange.right.start, end: lastRange.right.end},
+        };
+        button.dispatchEvent(
+          new CustomEvent<ContentLoadNeededEventDetail>('content-load-needed', {
+            detail: {
+              lineRange,
+            },
+            bubbles: true,
+            composed: true,
+          })
+        );
+      });
+    } else {
+      button.addEventListener('tap', e => {
+        const event = e as ContextEvent;
+        event.detail = {
+          groups,
+          section,
+          numLines,
+        };
+        // Let it bubble up the DOM tree.
+      });
+    }
+
+    return button;
+  }
+
+  _createLineEl(
+    line: GrDiffLine,
+    number: LineNumber,
+    type: GrDiffLineType,
+    side: Side
+  ) {
+    const td = this._createElement('td');
+    td.classList.add(side);
+    if (line.type === GrDiffLineType.BLANK) {
+      return td;
+    }
+    if (line.type === GrDiffLineType.BOTH || line.type === type) {
+      td.classList.add('lineNum');
+      td.dataset['value'] = number.toString();
+
+      if (this._prefs.show_file_comment_button === false && number === 'FILE') {
+        return td;
+      }
+
+      const button = this._createElement('button');
+      td.appendChild(button);
+      button.tabIndex = -1;
+      button.classList.add('lineNumButton');
+      button.classList.add(side);
+      button.dataset['value'] = number.toString();
+      button.textContent = number === 'FILE' ? 'File' : number.toString();
+
+      // 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 === GrDiffLineType.REMOVE) {
+          button.setAttribute('aria-label', `${number} removed`);
+        } else if (line.type === GrDiffLineType.ADD) {
+          button.setAttribute('aria-label', `${number} added`);
+        }
+      }
+    }
+
+    return td;
+  }
+
+  _createTextEl(
+    lineNumberEl: HTMLElement | null,
+    line: GrDiffLine,
+    side?: Side
+  ) {
+    const td = this._createElement('td');
+    if (line.type !== GrDiffLineType.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);
+
+    if (line.beforeNumber !== 'FILE') {
+      const lineLimit = !this._prefs.line_wrapping
+        ? this._prefs.line_length
+        : Infinity;
+      const contentText = this._formatText(
+        line.text,
+        this._prefs.tab_size,
+        lineLimit
+      );
+
+      if (side) {
+        contentText.setAttribute('data-side', side);
+      }
+
+      if (lineNumberEl) {
+        for (const layer of this.layers) {
+          if (typeof layer.annotate === 'function') {
+            layer.annotate(contentText, lineNumberEl, line);
+          }
+        }
+      } else {
+        console.error('The lineNumberEl is null, skipping layer annotations.');
+      }
+
+      td.appendChild(contentText);
+    } else {
+      td.classList.add('file');
+    }
+
+    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 text The text to be formatted.
+   * @param tabSize The width of each tab stop.
+   * @param lineLimit The column after which to wrap lines.
+   */
+  _formatText(text: string, tabSize: number, lineLimit: number): HTMLElement {
+    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))
+          );
+          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;
+            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;
+        }
+      }
+    }
+    return contentText;
+  }
+
+  /**
+   * Returns a <span> element holding a '\t' character, that will visually
+   * occupy |tabSize| many columns.
+   *
+   * @param tabSize The effective size of this tab stop.
+   */
+  _getTabWrapper(tabSize: number): HTMLElement {
+    // Force this to be a number to prevent arbitrary injection.
+    const result = this._createElement('span', 'tab');
+    result.setAttribute(
+      'style',
+      `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
+    );
+    result.innerText = '\t';
+    return result;
+  }
+
+  _createElement(tagName: string, classStr?: string): HTMLElement {
+    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;
+  }
+
+  _handleLayerUpdate(start: LineNumber, end: LineNumber, side: 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.
+   */
+  abstract _getNextContentOnSide(
+    content: HTMLElement,
+    side: Side
+  ): HTMLElement | null;
+
+  /**
+   * Gets configuration for creating move controls for chunks marked with
+   * dueToMove
+   */
+  abstract _getMoveControlsConfig(): {
+    numberOfCells: number;
+    movedOutIndex: number;
+    movedInIndex: number;
+  };
+
+  /**
+   * Determines whether the given group is either totally an addition or totally
+   * a removal.
+   */
+  _isTotal(group: GrDiffGroup): boolean {
+    return (
+      group.type === GrDiffGroupType.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.
+   */
+  setBlame(blame: BlameInfo[] | null) {
+    this._blameInfo = blame;
+    if (!blame) return;
+
+    // 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);
+          if (blame) el.appendChild(blame);
+        }
+      }
+    }
+  }
+
+  _buildMoveControls(group: GrDiffGroup) {
+    const movedIn = group.adds.length > 0;
+    const {
+      numberOfCells,
+      movedOutIndex,
+      movedInIndex,
+    } = this._getMoveControlsConfig();
+
+    let controlsClass;
+    let descriptionText;
+    let descriptionIndex;
+    if (movedIn) {
+      controlsClass = 'movedIn';
+      descriptionIndex = movedInIndex;
+      descriptionText = 'Moved in';
+    } else {
+      controlsClass = 'movedOut';
+      descriptionIndex = movedOutIndex;
+      descriptionText = 'Moved out';
+    }
+    const controls = document.createElement('tr');
+    const cells = [...Array(numberOfCells).keys()].map(() =>
+      document.createElement('td')
+    );
+    controls.classList.add('moveControls', controlsClass);
+    cells[descriptionIndex].classList.add('moveDescription');
+    cells[descriptionIndex].textContent = descriptionText;
+    cells.forEach(c => {
+      controls.appendChild(c);
+    });
+    return controls;
+  }
+
+  /**
+   * Find the blame cell for a given line number.
+   */
+  _getBlameByLineNum(lineNum: number): Element | null {
+    return this._outputEl.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.
+   *
+   * @return The commit information.
+   */
+  _getBlameCommitForBaseLine(lineNum: LineNumber) {
+    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;
+        }
+      }
+    }
+    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 commit Optionally provide the commit object, so that
+   *     it does not need to be searched.
+   */
+  _getBlameForBaseLine(
+    lineNum: LineNumber,
+    commit: BlameInfo | null = this._getBlameCommitForBaseLine(lineNum)
+  ): HTMLElement | null {
+    if (!commit) {
+      return null;
+    }
+
+    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('a', 'blameDate');
+    shaNode.innerText = `${date}`;
+    shaNode.setAttribute('href', `${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.
+   */
+  _createBlameCell(lineNumber: LineNumber): HTMLTableDataCellElement {
+    const blameTd = this._createElement(
+      'td',
+      'blame'
+    ) as HTMLTableDataCellElement;
+    blameTd.setAttribute('data-line-number', lineNumber.toString());
+    if (lineNumber) {
+      const content = this._getBlameForBaseLine(lineNumber);
+      if (content) {
+        blameTd.appendChild(content);
+      }
+    }
+    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.
+   */
+  _getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
+    let row: HTMLElement | null = content;
+    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
+    return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
+  }
+}
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
deleted file mode 100644
index 0b9ae5b..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ /dev/null
@@ -1,539 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 DiffSides = {
-  LEFT: 'left',
-  RIGHT: 'right',
-};
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
-const ScrollBehavior = {
-  KEEP_VISIBLE: 'keep-visible',
-  NEVER: 'never',
-};
-
-const LEFT_SIDE_CLASS = 'target-side-left';
-const RIGHT_SIDE_CLASS = 'target-side-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,
-      },
-      /** @type {!HTMLElement|undefined} */
-      diffRow: {
-        type: Object,
-        notify: true,
-        observer: '_rowChanged',
-      },
-
-      /**
-       * The diff views to cursor through and listen to.
-       */
-      diffs: {
-        type: Array,
-        value() { return []; },
-      },
-
-      /**
-       * If set, the cursor will attempt to move to the line number (instead of
-       * the first chunk) the next time the diff renders. It is set back to null
-       * when used. It should be only used if you want the line to be focused
-       * after initialization of the component and page should scroll
-       * to that position. This parameter should be set at most for one gr-diff
-       * element in the page.
-       *
-       * @type {?number}
-       */
-      initialLineNumber: {
-        type: Number,
-        value: null,
-      },
-
-      /**
-       * The scroll behavior for the cursor. Values are 'never' and
-       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
-       * the viewport.
-       */
-      _scrollBehavior: {
-        type: String,
-        value: ScrollBehavior.KEEP_VISIBLE,
-      },
-
-      _focusOnMove: {
-        type: Boolean,
-        value: true,
-      },
-
-      _listeningForScroll: Boolean,
-
-      /**
-       * 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)',
-    ];
-  }
-
-  constructor() {
-    super();
-    this._boundHandleWindowScroll = () => this._handleWindowScroll();
-    this._boundHandleDiffRenderStart = () => this._handleDiffRenderStart();
-    this._boundHandleDiffRenderContent = () => this._handleDiffRenderContent();
-    this._boundHandleDiffLineSelected = e => this._handleDiffLineSelected(e);
-  }
-
-  /** @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,
-      }));
-    });
-  }
-
-  /** @override */
-  connectedCallback() {
-    super.connectedCallback();
-    // Catch when users are scrolling as the view loads.
-    window.addEventListener('scroll', this._boundHandleWindowScroll);
-  }
-
-  /** @override */
-  disconnectedCallback() {
-    super.disconnectedCallback();
-    window.removeEventListener('scroll', this._boundHandleWindowScroll);
-  }
-
-  moveLeft() {
-    this.side = DiffSides.LEFT;
-    if (this._isTargetBlank()) {
-      this.moveUp();
-    }
-  }
-
-  moveRight() {
-    this.side = DiffSides.RIGHT;
-    if (this._isTargetBlank()) {
-      this.moveUp();
-    }
-  }
-
-  moveDown() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.next(this._rowHasSide.bind(this));
-    } else {
-      this.$.cursorManager.next();
-    }
-  }
-
-  moveUp() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.previous(this._rowHasSide.bind(this));
-    } else {
-      this.$.cursorManager.previous();
-    }
-  }
-
-  moveToVisibleArea() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.moveToVisibleArea(
-          this._rowHasSide.bind(this));
-    } else {
-      this.$.cursorManager.moveToVisibleArea();
-    }
-  }
-
-  moveToNextChunk(opt_clipToTop) {
-    this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
-        target => target.parentNode.scrollHeight, opt_clipToTop);
-    this._fixSide();
-  }
-
-  moveToPreviousChunk() {
-    this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
-    this._fixSide();
-  }
-
-  moveToNextCommentThread() {
-    this.$.cursorManager.next(this._rowHasThread.bind(this));
-    this._fixSide();
-  }
-
-  moveToPreviousCommentThread() {
-    this.$.cursorManager.previous(this._rowHasThread.bind(this));
-    this._fixSide();
-  }
-
-  /**
-   * @param {number} number
-   * @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);
-    }
-  }
-
-  /**
-   * Get the line number element targeted by the cursor row and side.
-   *
-   * @return {?Element|undefined}
-   */
-  getTargetLineElement() {
-    let lineElSelector = '.lineNum';
-
-    if (!this.diffRow) {
-      return;
-    }
-
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
-    }
-
-    return this.diffRow.querySelector(lineElSelector);
-  }
-
-  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();
-  }
-
-  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);
-      }
-    }
-  }
-
-  /**
-   * 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');
-      }
-    } else {
-      cell = this.diffRow.querySelector('.lineNum.' + this.side);
-    }
-    if (!cell) { return null; }
-
-    const number = cell.getAttribute('data-value');
-    if (!number || number === 'FILE') { return null; }
-
-    return {
-      leftSide: cell.matches('.left'),
-      number: parseInt(number, 10),
-    };
-  }
-
-  _getViewMode() {
-    if (!this.diffRow) {
-      return null;
-    }
-
-    if (this.diffRow.classList.contains('side-by-side')) {
-      return DiffViewMode.SIDE_BY_SIDE;
-    } else {
-      return DiffViewMode.UNIFIED;
-    }
-  }
-
-  _rowHasSide(row) {
-    const selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
-        ' + .content';
-    return !!row.querySelector(selector);
-  }
-
-  _isFirstRowOfChunk(row) {
-    const parentClassList = row.parentNode.classList;
-    return parentClassList.contains('section') &&
-        parentClassList.contains('delta') &&
-        !row.previousSibling;
-  }
-
-  _rowHasThread(row) {
-    return row.querySelector('.thread-group');
-  }
-
-  /**
-   * If we jumped to a row where there is no content on the current side then
-   * 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);
-      }
-
-      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);
-      }
-    }
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
new file mode 100644
index 0000000..e7fb30e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -0,0 +1,612 @@
+/**
+ * @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 '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {
+  AbortStop,
+  CursorMoveResult,
+  GrCursorManager,
+  isTargetable,
+} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-cursor_html';
+import {ScrollMode, Side} from '../../../constants/constants';
+import {customElement, property, observe} from '@polymer/decorators';
+import {GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {PolymerDomWrapper} from '../../../types/types';
+import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiff} from '../gr-diff/gr-diff';
+
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
+
+type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
+
+const LEFT_SIDE_CLASS = 'target-side-left';
+const RIGHT_SIDE_CLASS = 'target-side-right';
+
+// Time in which pressing n key again after the toast navigates to next file
+const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
+
+export interface GrDiffCursor {
+  $: {
+    cursorManager: GrCursorManager;
+  };
+}
+
+@customElement('gr-diff-cursor')
+export class GrDiffCursor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  private _boundHandleWindowScroll: () => void;
+
+  private _boundHandleDiffRenderStart: () => void;
+
+  private _boundHandleDiffRenderContent: () => void;
+
+  private _boundHandleDiffLineSelected: (e: Event) => void;
+
+  private _preventAutoScrollOnManualScroll = false;
+
+  private lastDisplayedNavigateToNextFileToast: number | null = null;
+
+  @property({type: String})
+  side = Side.RIGHT;
+
+  @property({type: Object, notify: true, observer: '_rowChanged'})
+  diffRow?: HTMLElement;
+
+  @property({type: Object})
+  diffs: GrDiff[] = [];
+
+  /**
+   * If set, the cursor will attempt to move to the line number (instead of
+   * the first chunk) the next time the diff renders. It is set back to null
+   * when used. It should be only used if you want the line to be focused
+   * after initialization of the component and page should scroll
+   * to that position. This parameter should be set at most for one gr-diff
+   * element in the page.
+   */
+  @property({type: Number})
+  initialLineNumber: number | null = null;
+
+  /**
+   * The scroll behavior for the cursor. Values are 'never' and
+   * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+   * the viewport.
+   */
+  @property({type: String})
+  _scrollMode = ScrollMode.KEEP_VISIBLE;
+
+  @property({type: Boolean})
+  _focusOnMove = true;
+
+  @property({type: Boolean})
+  _listeningForScroll = false;
+
+  constructor() {
+    super();
+    this._boundHandleWindowScroll = () => this._handleWindowScroll();
+    this._boundHandleDiffRenderStart = () => this._handleDiffRenderStart();
+    this._boundHandleDiffRenderContent = () => this._handleDiffRenderContent();
+    this._boundHandleDiffLineSelected = (e: Event) =>
+      this._handleDiffLineSelected(e);
+  }
+
+  /** @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,
+        })
+      );
+    });
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    // Catch when users are scrolling as the view loads.
+    window.addEventListener('scroll', this._boundHandleWindowScroll);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    window.removeEventListener('scroll', this._boundHandleWindowScroll);
+  }
+
+  moveLeft() {
+    this.side = Side.LEFT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
+
+  moveRight() {
+    this.side = Side.RIGHT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
+
+  moveDown() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.cursorManager.next({
+        filter: (row: Element) => this._rowHasSide(row),
+      });
+    } else {
+      this.$.cursorManager.next();
+    }
+  }
+
+  moveUp() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.cursorManager.previous({
+        filter: (row: Element) => this._rowHasSide(row),
+      });
+    } else {
+      this.$.cursorManager.previous();
+    }
+  }
+
+  moveToVisibleArea() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.cursorManager.moveToVisibleArea((row: Element) =>
+        this._rowHasSide(row)
+      );
+    } else {
+      this.$.cursorManager.moveToVisibleArea();
+    }
+  }
+
+  moveToNextChunk(clipToTop?: boolean, navigateToNextFile?: boolean) {
+    const result = this.$.cursorManager.next({
+      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+      getTargetHeight: target =>
+        (target?.parentNode as HTMLElement)?.scrollHeight || 0,
+      clipToTop,
+    });
+    /*
+     * If user presses n on the last diff chunk, show a toast informing user
+     * that pressing n again will navigate them to next unreviewed file.
+     * If click happens within the time limit, then navigate to next file
+     */
+    if (
+      navigateToNextFile &&
+      result === CursorMoveResult.CLIPPED &&
+      this.$.cursorManager.isAtEnd()
+    ) {
+      if (
+        this.lastDisplayedNavigateToNextFileToast &&
+        Date.now() - this.lastDisplayedNavigateToNextFileToast <=
+          NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
+      ) {
+        // reset for next file
+        this.lastDisplayedNavigateToNextFileToast = null;
+        this.dispatchEvent(
+          new CustomEvent('navigate-to-next-unreviewed-file', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+      }
+      this.lastDisplayedNavigateToNextFileToast = Date.now();
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Press n again to navigate to next unreviewed file',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
+
+    this._fixSide();
+  }
+
+  moveToPreviousChunk() {
+    this.$.cursorManager.previous({
+      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+    });
+    this._fixSide();
+  }
+
+  moveToNextCommentThread() {
+    this.$.cursorManager.next({
+      filter: (row: HTMLElement) => this._rowHasThread(row),
+    });
+    this._fixSide();
+  }
+
+  moveToPreviousCommentThread() {
+    this.$.cursorManager.previous({
+      filter: (row: HTMLElement) => this._rowHasThread(row),
+    });
+    this._fixSide();
+  }
+
+  moveToLineNumber(number: number, side: Side, path?: string) {
+    const row = this._findRowByNumberAndFile(number, side, path);
+    if (row) {
+      this.side = side;
+      this.$.cursorManager.setCursor(row);
+    }
+  }
+
+  /**
+   * Get the line number element targeted by the cursor row and side.
+   */
+  getTargetLineElement(): HTMLElement | null {
+    let lineElSelector = '.lineNum';
+
+    if (!this.diffRow) {
+      return null;
+    }
+
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      lineElSelector += this.side === Side.LEFT ? '.left' : '.right';
+    }
+
+    return this.diffRow.querySelector(lineElSelector);
+  }
+
+  getTargetDiffElement(): GrDiff | null {
+    if (!this.diffRow) return null;
+
+    const hostOwner = (dom(this.diffRow) as PolymerDomWrapper).getOwnerRoot();
+    if (hostOwner?.host?.tagName === 'GR-DIFF') {
+      return hostOwner.host as GrDiff;
+    }
+    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
+      this._scrollMode = this.initialLineNumber
+        ? ScrollMode.KEEP_VISIBLE
+        : ScrollMode.NEVER;
+      if (this.initialLineNumber) {
+        this.moveToLineNumber(this.initialLineNumber, this.side);
+        this.initialLineNumber = null;
+      } else {
+        this.moveToFirstChunk();
+      }
+    }
+    this.reInit();
+  }
+
+  reInit() {
+    this._scrollMode = ScrollMode.KEEP_VISIBLE;
+  }
+
+  _handleWindowScroll() {
+    if (this._preventAutoScrollOnManualScroll) {
+      this._scrollMode = ScrollMode.NEVER;
+      this._focusOnMove = false;
+      this._preventAutoScrollOnManualScroll = false;
+    }
+  }
+
+  reInitAndUpdateStops() {
+    this.reInit();
+    this._updateStops();
+  }
+
+  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: Event) {
+    const customEvent = event as CustomEvent;
+    this.moveToLineNumber(
+      customEvent.detail.number,
+      customEvent.detail.side,
+      customEvent.detail.path
+    );
+  }
+
+  createCommentInPlace() {
+    const diffWithRangeSelected = this.diffs.find(diff =>
+      diff.isRangeSelected()
+    );
+    if (diffWithRangeSelected) {
+      diffWithRangeSelected.createRangeComment();
+    } else {
+      const line = this.getTargetLineElement();
+      const diff = this.getTargetDiffElement();
+      if (diff && line) {
+        diff.addDraftAtLine(line);
+      }
+    }
+  }
+
+  /**
+   * 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
+   */
+  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');
+      }
+    } else {
+      cell = this.diffRow.querySelector('.lineNum.' + this.side);
+    }
+    if (!cell) {
+      return null;
+    }
+
+    const number = cell.getAttribute('data-value');
+    if (!number || number === 'FILE') {
+      return null;
+    }
+
+    return {
+      leftSide: cell.matches('.left'),
+      number: Number(number),
+    };
+  }
+
+  _getViewMode() {
+    if (!this.diffRow) {
+      return null;
+    }
+
+    if (this.diffRow.classList.contains('side-by-side')) {
+      return DiffViewMode.SIDE_BY_SIDE;
+    } else {
+      return DiffViewMode.UNIFIED;
+    }
+  }
+
+  _rowHasSide(row: Element) {
+    const selector =
+      (this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
+    return !!row.querySelector(selector);
+  }
+
+  _isFirstRowOfChunk(row: HTMLElement) {
+    const parentClassList = (row.parentNode as HTMLElement).classList;
+    const isInChunk =
+      parentClassList.contains('section') && parentClassList.contains('delta');
+    const previousRow = row.previousSibling as HTMLElement;
+    const firstContentRow =
+      !previousRow || previousRow.classList.contains('moveControls');
+    return isInChunk && firstContentRow;
+  }
+
+  _rowHasThread(row: HTMLElement): boolean {
+    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 === Side.LEFT ? Side.RIGHT : Side.LEFT;
+    }
+  }
+
+  _isTargetBlank() {
+    if (!this.diffRow) {
+      return false;
+    }
+
+    const actions = this._getActionsForRow();
+    return (
+      (this.side === Side.LEFT && !actions.left) ||
+      (this.side === Side.RIGHT && !actions.right)
+    );
+  }
+
+  _rowChanged(_: HTMLElement, oldRow: HTMLElement) {
+    if (oldRow) {
+      oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+    }
+    this._updateSideClass();
+  }
+
+  @observe('side')
+  _updateSideClass() {
+    if (!this.diffRow) {
+      return;
+    }
+    this.toggleClass(LEFT_SIDE_CLASS, this.side === Side.LEFT, this.diffRow);
+    this.toggleClass(RIGHT_SIDE_CLASS, this.side === Side.RIGHT, this.diffRow);
+  }
+
+  _isActionType(type: GrDiffRowType) {
+    return (
+      type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
+    );
+  }
+
+  _getActionsForRow() {
+    const actions = {left: false, right: false};
+    if (this.diffRow) {
+      actions.left = this._isActionType(
+        this.diffRow.getAttribute('left-type') as GrDiffRowType
+      );
+      actions.right = this._isActionType(
+        this.diffRow.getAttribute('right-type') as GrDiffRowType
+      );
+    }
+    return actions;
+  }
+
+  _updateStops() {
+    this.$.cursorManager.stops = this.diffs.reduce(
+      (stops: HTMLElement[], diff) => stops.concat(diff.getCursorStops()),
+      []
+    );
+  }
+
+  /**
+   * Setup and tear down on-render listeners for any diffs that are added or
+   * removed from the cursor.
+   */
+  @observe('diffs.splices')
+  _diffsChanged(changeRecord: PolymerSpliceChange<GrDiff[]>) {
+    if (!changeRecord) {
+      return;
+    }
+
+    this._updateStops();
+
+    let splice;
+    let i;
+    for (
+      let spliceIdx = 0;
+      changeRecord.indexSplices && spliceIdx < changeRecord.indexSplices.length;
+      spliceIdx++
+    ) {
+      splice = changeRecord.indexSplices[spliceIdx];
+
+      // Removals must come before additions, because the gr-diff instances
+      // might be the same.
+      for (i = 0; i < 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
+        );
+      }
+
+      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
+        );
+      }
+    }
+  }
+
+  _findRowByNumberAndFile(
+    targetNumber: number,
+    side: Side,
+    path?: string
+  ): HTMLElement | undefined {
+    let stops: Array<HTMLElement | AbortStop>;
+    if (path) {
+      const diff = this.diffs.filter(diff => diff.path === path)[0];
+      stops = diff.getCursorStops();
+    } else {
+      stops = this.$.cursorManager.stops;
+    }
+    // Sadly needed for type narrowing to understand that the result is always
+    // targetable.
+    const targetableStops: HTMLElement[] = stops.filter(isTargetable);
+    const selector = `.lineNum.${side}[data-value="${targetNumber}"]`;
+    return targetableStops.find(stop => stop.querySelector(selector));
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-cursor': 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
deleted file mode 100644
index 1ac47f6..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
new file mode 100644
index 0000000..1539a22
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <gr-cursor-manager
+    id="cursorManager"
+    scroll-mode="[[_scrollMode]]"
+    cursor-target-class="target-row"
+    focus-on-move="[[_focusOnMove]]"
+    target="{{diffRow}}"
+  ></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
deleted file mode 100644
index 77e5179..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ /dev/null
@@ -1,430 +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">
-<meta charset="utf-8">
-<title>gr-diff-cursor</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-diff></gr-diff>
-    <gr-diff-cursor></gr-diff-cursor>
-    <gr-rest-api-interface></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<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 => {
-      // 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('diff cursor functionality (unified)', () => {
-      // The cursor has been initialized to the first delta.
-      assert.isOk(cursorElement.diffRow);
-
-      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();
-
-      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('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');
-      done();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    diffElement._diffChanged(getMockDiffResponse());
-  });
-
-  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');
-      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;
-    });
-
-    test('adds new draft for selected line on the left', done => {
-      cursorElement.moveToLineNumber(2, 'left');
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
-        assert.equal(lineNum, 2);
-        assert.equal(range, undefined);
-        assert.equal(patchNum, 1);
-        assert.equal(side, 'left');
-        done();
-      });
-      cursorElement.createCommentInPlace();
-    });
-
-    test('adds draft for selected line on the right', done => {
-      cursorElement.moveToLineNumber(4, 'right');
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
-        assert.equal(lineNum, 4);
-        assert.equal(range, undefined);
-        assert.equal(patchNum, 2);
-        assert.equal(side, 'right');
-        done();
-      });
-      cursorElement.createCommentInPlace();
-    });
-
-    test('createCommentInPlace creates comment for range if selected', done => {
-      const someRange = {
-        start_line: 2,
-        start_character: 3,
-        end_line: 6,
-        end_character: 1,
-      };
-      diffElement.$.highlights.selectedRange = {
-        side: 'right',
-        range: someRange,
-      };
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
-        assert.equal(lineNum, 6);
-        assert.equal(range, someRange);
-        assert.equal(patchNum, 2);
-        assert.equal(side, 'right');
-        done();
-      });
-      cursorElement.createCommentInPlace();
-    });
-
-    test('createCommentInPlace ignores call if nothing is selected', () => {
-      const createRangeCommentStub = sandbox.stub(diffElement,
-          'createRangeComment');
-      const addDraftAtLineStub = sandbox.stub(diffElement, 'addDraftAtLine');
-      cursorElement.diffRow = undefined;
-      cursorElement.createCommentInPlace();
-      assert.isFalse(createRangeCommentStub.called);
-      assert.isFalse(addDraftAtLineStub.called);
-    });
-  });
-
-  test('getAddress', () => {
-    // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursorElement.getAddress(),
-        {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-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
new file mode 100644
index 0000000..8e95f3d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -0,0 +1,494 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-diff/gr-diff.js';
+import './gr-diff-cursor.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+  <gr-diff></gr-diff>
+  <gr-diff-cursor></gr-diff-cursor>
+  <gr-rest-api-interface></gr-rest-api-interface>
+`);
+
+const emptyFixture = fixtureFromElement('div');
+
+suite('gr-diff-cursor tests', () => {
+  let cursorElement;
+  let diffElement;
+  let diff;
+
+  setup(done => {
+    const fixtureElems = basicFixture.instantiate();
+    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);
+
+    diff = getMockDiffResponse();
+    restAPI.getDiffPreferences().then(prefs => {
+      diffElement.prefs = prefs;
+      diffElement.diff = diff;
+    });
+  });
+
+  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(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._scrollMode, 'keep-visible');
+
+    cursorElement._handleDiffRenderStart();
+    assert.isTrue(cursorElement._focusOnMove);
+
+    cursorElement._handleWindowScroll();
+    assert.equal(cursorElement._scrollMode, 'never');
+    assert.isFalse(cursorElement._focusOnMove);
+
+    cursorElement._handleDiffRenderContent();
+    assert.isTrue(cursorElement._focusOnMove);
+
+    cursorElement.reInitCursor();
+    assert.equal(cursorElement._scrollMode, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sinon.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 => {
+      // 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('diff cursor functionality (unified)', () => {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursorElement.diffRow);
+
+      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();
+
+      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('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 = 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 moved over.
+    const previousIndex = currentIndex;
+    currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursorElement.side, 'left');
+  });
+
+  suite('moved chunks (dueToMove=true)', () => {
+    setup(done => {
+      const renderHandler = function() {
+        diffElement.removeEventListener('render', renderHandler);
+        cursorElement.reInitCursor();
+        done();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {...diff, content: [
+        {
+          ab: [
+            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
+          ],
+        },
+        {
+          b: [
+            'Nullam neque, ligula ac, id blandit.',
+            'Sagittis tincidunt torquent, tempor nunc amet.',
+            'At rhoncus id.',
+          ],
+          due_to_move: true,
+        },
+        {
+          ab: [
+            'Sem nascetur, erat ut, non in.',
+          ],
+        },
+        {
+          a: [
+            'Nullam neque, ligula ac, id blandit.',
+            'Sagittis tincidunt torquent, tempor nunc amet.',
+            'At rhoncus id.',
+          ],
+          due_to_move: true,
+        },
+        {
+          ab: [
+            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+          ],
+        },
+      ]};
+    });
+
+    test('chunk skip functionality', () => {
+      const chunks = diffElement.root.querySelectorAll(
+          '.section.delta');
+      const indexOfChunk = function(chunk) {
+        return Array.prototype.indexOf.call(chunks, chunk);
+      };
+
+      // We should be initialized to the first chunk (b)
+      let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      assert.equal(currentIndex, 0);
+      assert.equal(cursorElement.side, 'right');
+
+      // Move to the next chunk.
+      cursorElement.moveToNextChunk();
+
+      // Since the next chunk only has content on the left side (a). we should have been
+      // automatically moved over.
+      const previousIndex = currentIndex;
+      currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      assert.equal(currentIndex, previousIndex + 1);
+      assert.equal(cursorElement.side, 'left');
+    });
+  });
+
+  test('navigate to next unreviewed file via moveToNextChunk', () => {
+    const cursorManager =
+        cursorElement.shadowRoot.querySelector('#cursorManager');
+    cursorManager.index = cursorManager.stops.length - 1;
+    const dispatchEventStub = sinon.stub(cursorElement, 'dispatchEvent');
+    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
+        /* opt_navigateToNextFile = */true);
+    assert.isTrue(dispatchEventStub.called);
+    assert.equal(dispatchEventStub.getCall(1).args[0].type, 'show-alert');
+
+    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
+        /* opt_navigateToNextFile = */true);
+    assert.equal(dispatchEventStub.getCall(2).args[0].type,
+        'navigate-to-next-unreviewed-file');
+  });
+
+  test('initialLineNumber not provided', done => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+    const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk')
+        .callsFake(
+            () => { scrollBehaviorDuringMove = cursorElement._scrollMode; });
+
+    function renderHandler() {
+      diffElement.removeEventListener('render', renderHandler);
+      cursorElement.reInitCursor();
+      assert.isFalse(moveToNumStub.called);
+      assert.isTrue(moveToChunkStub.called);
+      assert.equal(scrollBehaviorDuringMove, 'never');
+      assert.equal(cursorElement._scrollMode, 'keep-visible');
+      done();
+    }
+    diffElement.addEventListener('render', renderHandler);
+    diffElement._diffChanged(getMockDiffResponse());
+  });
+
+  test('initialLineNumber provided', done => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber')
+        .callsFake(
+            () => { scrollBehaviorDuringMove = cursorElement._scrollMode; });
+    const moveToChunkStub = sinon.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._scrollMode, 'keep-visible');
+      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;
+    });
+
+    test('adds new draft for selected line on the left', done => {
+      cursorElement.moveToLineNumber(2, 'left');
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side, patchNum} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(patchNum, 1);
+        assert.equal(side, 'left');
+        done();
+      });
+      cursorElement.createCommentInPlace();
+    });
+
+    test('adds draft for selected line on the right', done => {
+      cursorElement.moveToLineNumber(4, 'right');
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side, patchNum} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(patchNum, 2);
+        assert.equal(side, 'right');
+        done();
+      });
+      cursorElement.createCommentInPlace();
+    });
+
+    test('createCommentInPlace creates comment for range if selected', done => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
+      };
+      diffElement.$.highlights.selectedRange = {
+        side: 'right',
+        range: someRange,
+      };
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side, patchNum} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(patchNum, 2);
+        assert.equal(side, 'right');
+        done();
+      });
+      cursorElement.createCommentInPlace();
+    });
+
+    test('createCommentInPlace ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sinon.stub(diffElement,
+          'createRangeComment');
+      const addDraftAtLineStub = sinon.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 = 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 => {
+    sinon.spy(cursorElement, '_updateStops');
+    MockInteractions.tap(diffElement.shadowRoot
+        .querySelector('.showContext'));
+    flush(() => {
+      assert.isTrue(cursorElement._updateStops.called);
+      done();
+    });
+  });
+
+  suite('gr-diff-cursor event tests', () => {
+    let someEmptyDiv;
+
+    setup(() => {
+      someEmptyDiv = emptyFixture.instantiate();
+    });
+
+    teardown(() => sinon.restore());
+
+    test('ready is fired after component is rendered', done => {
+      const cursorElement = document.createElement('gr-diff-cursor');
+      cursorElement.addEventListener('ready', () => {
+        done();
+      });
+      someEmptyDiv.appendChild(cursorElement);
+    });
+  });
+});
+
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
deleted file mode 100644
index 4006d13..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
+++ /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.
- */
-
-// 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]/;
-
-export 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);
-  },
-
-  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;
-
-    if (parent instanceof Element) {
-      childNodes = Array.from(parent.childNodes);
-    } else if (parent instanceof Text) {
-      childNodes = [parent];
-      parent = parent.parentNode;
-    } else {
-      return;
-    }
-
-    const nestedNodes = [];
-    for (let node of childNodes) {
-      const initialNodeLength = this.getLength(node);
-      // If the current node is completely before the offset.
-      if (offset > 0 && initialNodeLength <= offset) {
-        offset -= initialNodeLength;
-        continue;
-      }
-
-      if (offset > 0) {
-        node = this.splitNode(node, offset);
-        offset = 0;
-      }
-      if (this.getLength(node) > length) {
-        this.splitNode(node, length);
-      }
-      nestedNodes.push(node);
-
-      length -= this.getLength(node);
-      if (!length) break;
-    }
-
-    const wrapper = document.createElement(tagName);
-    const sanitizer = window.Polymer.sanitizeDOMValue;
-    for (const [name, value] of Object.entries(attributes)) {
-      wrapper.setAttribute(
-          name, sanitizer ?
-            sanitizer(value, name, 'attribute', wrapper) :
-            value);
-    }
-    for (const inner of nestedNodes) {
-      parent.replaceChild(wrapper, inner);
-      wrapper.appendChild(inner);
-    }
-  },
-
-  /**
-   * Surrounds the element's text at specified range in an ANNOTATION_TAG
-   * element. If the element has child elements, the range is split and
-   * applied as deeply as possible.
-   */
-  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 {
-        break;
-      }
-    }
-  },
-
-  /**
-   * 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 {
-        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);
-    }
-    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);
-      }
-      return tailNode;
-    } else {
-      return node.splitText(offset);
-    }
-  },
-
-  _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);
-    }
-  },
-};
-
-/**
- * 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.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
new file mode 100644
index 0000000..7420dc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
@@ -0,0 +1,287 @@
+/**
+ * @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 {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
+
+// 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]/;
+
+export const GrAnnotation = {
+  /**
+   * The DOM API textContent.length calculation is broken when the text
+   * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+   *
+   */
+  getLength(node: Node) {
+    return this.getStringLength(node.textContent || '');
+  },
+
+  getStringLength(str: string) {
+    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 parent the node whose contents will be annotated.
+   * If parent is Text then parent.parentNode must not be null
+   * @param offset the 0-based offset from which the annotation will
+   * start.
+   * @param length of the annotated text.
+   * @param elementSpec the spec to create the
+   * annotating element.
+   */
+  annotateWithElement(
+    parent: Node,
+    offset: number,
+    length: number,
+    elSpec: ElementSpec
+  ) {
+    const tagName = elSpec.tagName;
+    const attributes = elSpec.attributes || {};
+    let childNodes: Node[];
+
+    if (parent instanceof Element) {
+      childNodes = Array.from(parent.childNodes);
+    } else if (parent instanceof Text) {
+      childNodes = [parent];
+      parent = parent.parentNode!;
+    } else {
+      return;
+    }
+
+    const nestedNodes: Node[] = [];
+    for (let node of childNodes) {
+      const initialNodeLength = this.getLength(node);
+      // If the current node is completely before the offset.
+      if (offset > 0 && initialNodeLength <= offset) {
+        offset -= initialNodeLength;
+        continue;
+      }
+
+      if (offset > 0) {
+        node = this.splitNode(node, offset);
+        offset = 0;
+      }
+      if (this.getLength(node) > length) {
+        this.splitNode(node, length);
+      }
+      nestedNodes.push(node);
+
+      length -= this.getLength(node);
+      if (!length) break;
+    }
+
+    const wrapper = document.createElement(tagName);
+    const sanitizer = getSanitizeDOMValue();
+    for (let [name, value] of Object.entries(attributes)) {
+      if (!value) continue;
+      if (sanitizer) {
+        value = sanitizer(value, name, 'attribute', wrapper) as string;
+      }
+      wrapper.setAttribute(name, 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: HTMLElement,
+    offset: number,
+    length: number,
+    cssClass: string
+  ) {
+    const nodes: Array<HTMLElement | Text> = [].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 {
+        break;
+      }
+    }
+  },
+
+  /**
+   * Wraps node in annotation tag with cssClass, replacing the node in DOM.
+   */
+  wrapInHighlight(node: Element | Text, cssClass: string) {
+    let hl;
+    if (!(node instanceof Text) && node.tagName === ANNOTATION_TAG) {
+      hl = node;
+      hl.classList.add(cssClass);
+    } else {
+      hl = document.createElement(ANNOTATION_TAG);
+      hl.className = cssClass;
+      if (node.parentElement) node.parentElement.replaceChild(hl, node);
+      hl.appendChild(node);
+    }
+    return hl;
+  },
+
+  /**
+   * Splits Text Node and wraps it in hl with cssClass.
+   * Wraps trailing part after split, tailing one if firstPart is true.
+   */
+  splitAndWrapInHighlight(
+    node: Text,
+    offset: number,
+    cssClass: string,
+    firstPart?: boolean
+  ) {
+    if (this.getLength(node) === offset || offset === 0) {
+      return this.wrapInHighlight(node, cssClass);
+    } else {
+      if (firstPart) {
+        this.splitNode(node, offset);
+        // Node points to first part of the Text, second one is sibling.
+      } else {
+        // if node is Text then splitNode will return a Text
+        node = this.splitNode(node, offset) as Text;
+      }
+      return this.wrapInHighlight(node, cssClass);
+    }
+  },
+
+  /**
+   * Splits Node at offset.
+   * If Node is Element, it's cloned and the node at offset is split too.
+   */
+  splitNode(element: Node, offset: number) {
+    if (element instanceof Text) {
+      return this.splitTextNode(element, offset);
+    }
+    const tail = element.cloneNode(false);
+
+    if (element.parentElement)
+      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 (node && this.getLength(node) > offset) {
+      tail.appendChild(this.splitNode(node, offset));
+    }
+    while (node && 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
+   *
+   * @return Trailing Text Node.
+   */
+  splitTextNode(node: Text, offset: number) {
+    if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
+      // TODO (viktard): Polyfill Array.from for IE10.
+      const head = Array.from(node.textContent);
+      const tail = head.splice(offset);
+      const parent = node.parentNode;
+
+      // Split the content of the original node.
+      node.textContent = head.join('');
+
+      const tailNode = document.createTextNode(tail.join(''));
+      if (parent) {
+        parent.insertBefore(tailNode, node.nextSibling);
+      }
+      return tailNode;
+    } else {
+      return node.splitText(offset);
+    }
+  },
+
+  _annotateText(node: Text, offset: number, length: number, cssClass: string) {
+    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
+      );
+    }
+  },
+};
+
+/**
+ * Data used to construct an element.
+ *
+ */
+export interface ElementSpec {
+  tagName: string;
+  attributes?: {[attributeName: string]: string | undefined};
+}
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
deleted file mode 100644
index 2bda950..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
+++ /dev/null
@@ -1,298 +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">
-<meta charset="utf-8">
-<title>gr-annotation</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>
-    <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-  </template>
-</test-fixture>
-
-<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(() => {
-      originalSanitizeDOMValue = sanitizeDOMValue;
-      assert.isDefined(originalSanitizeDOMValue);
-      mockSanitize = sandbox.spy(originalSanitizeDOMValue);
-      setSanitizeDOMValue(mockSanitize);
-    });
-
-    teardown(() => {
-      setSanitizeDOMValue(originalSanitizeDOMValue);
-    });
-
-    test('annotates when fully contained', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789');
-    });
-
-    test('annotates when spanning multiple nodes', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateElement(container, 5, length, 'testclass');
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0' +
-          '<test-wrapper>' +
-          '1234' +
-          '<hl class="testclass">567890</hl>' +
-          '</test-wrapper>' +
-          '<hl class="testclass">1234</hl>' +
-          '56789');
-    });
-
-    test('annotates text node', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateWithElement(
-          container.childNodes[0], 1, length, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789');
-    });
-
-    test('handles zero-length nodes', () => {
-      const container = document.createElement('div');
-      container.appendChild(document.createTextNode('0123456789'));
-      container.appendChild(document.createElement('span'));
-      container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(
-          container, 1, 10, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
-    });
-
-    test('sets sanitized attributes', () => {
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      const attributes = {
-        'href': 'foo',
-        'data-foo': 'bar',
-        'class': 'hello world',
-      };
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper', attributes});
-      assert(mockSanitize.calledWith(
-          'foo', 'href', 'attribute', sinon.match.instanceOf(Element)));
-      assert(mockSanitize.calledWith(
-          'bar', 'data-foo', 'attribute', sinon.match.instanceOf(Element)));
-      assert(mockSanitize.calledWith(
-          'hello world',
-          'class',
-          'attribute',
-          sinon.match.instanceOf(Element)));
-      const el = container.querySelector('test-wrapper');
-      assert.equal(el.getAttribute('href'), 'foo');
-      assert.equal(el.getAttribute('data-foo'), 'bar');
-      assert.equal(el.getAttribute('class'), 'hello world');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
new file mode 100644
index 0000000..65f5e07
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
@@ -0,0 +1,281 @@
+/**
+ * @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 basicFixture = fixtureFromTemplate(html`
+<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+`);
+
+import '../../../test/common-test-setup-karma.js';
+import {GrAnnotation} from './gr-annotation.js';
+import {sanitizeDOMValue, setSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+suite('annotation', () => {
+  let str;
+  let parent;
+  let textNode;
+
+  setup(() => {
+    parent = basicFixture.instantiate();
+    textNode = parent.childNodes[0];
+    str = textNode.textContent;
+  });
+
+  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(() => {
+      originalSanitizeDOMValue = sanitizeDOMValue;
+      assert.isDefined(originalSanitizeDOMValue);
+      mockSanitize = sinon.spy(originalSanitizeDOMValue);
+      setSanitizeDOMValue(mockSanitize);
+    });
+
+    teardown(() => {
+      setSanitizeDOMValue(originalSanitizeDOMValue);
+    });
+
+    test('annotates when fully contained', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper'});
+
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>1234567890</test-wrapper>123456789');
+    });
+
+    test('annotates when spanning multiple nodes', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateElement(container, 5, length, 'testclass');
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper'});
+
+      assert.equal(
+          container.innerHTML,
+          '0' +
+          '<test-wrapper>' +
+          '1234' +
+          '<hl class="testclass">567890</hl>' +
+          '</test-wrapper>' +
+          '<hl class="testclass">1234</hl>' +
+          '56789');
+    });
+
+    test('annotates text node', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(
+          container.childNodes[0], 1, length, {tagName: 'test-wrapper'});
+
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>1234567890</test-wrapper>123456789');
+    });
+
+    test('handles zero-length nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(
+          container, 1, 10, {tagName: 'test-wrapper'});
+
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
+    });
+
+    test('sets sanitized attributes', () => {
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      const attributes = {
+        'href': 'foo',
+        'data-foo': 'bar',
+        'class': 'hello world',
+      };
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper', attributes});
+      assert(mockSanitize.calledWith(
+          'foo', 'href', 'attribute', sinon.match.instanceOf(Element)));
+      assert(mockSanitize.calledWith(
+          'bar', 'data-foo', 'attribute', sinon.match.instanceOf(Element)));
+      assert(mockSanitize.calledWith(
+          'hello world',
+          'class',
+          'attribute',
+          sinon.match.instanceOf(Element)));
+      const el = container.querySelector('test-wrapper');
+      assert.equal(el.getAttribute('href'), 'foo');
+      assert.equal(el.getAttribute('data-foo'), 'bar');
+      assert.equal(el.getAttribute('class'), 'hello world');
+    });
+  });
+});
+
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
deleted file mode 100644
index 00da805..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ /dev/null
@@ -1,548 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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,
-      },
-      loggedIn: Boolean,
-      /**
-       * querySelector can return null, so needs to be nullable.
-       *
-       * @type {?HTMLElement}
-       * */
-      _cachedDiffBuilder: Object,
-
-      /**
-       * 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,
-      },
-    };
-  }
-
-  /** @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 =
-          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');
-        }
-      }
-    }
-  }
-
-  _handleCommentThreadMouseenter(e) {
-    const threadEl = this._getThreadEl(e);
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], true);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
-  }
-
-  _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;
-    } 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);
-      }
-    }
-
-    return {
-      node,
-      side,
-      line,
-      column,
-    };
-  }
-
-  /**
-   * 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);
-  }
-
-  _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,
-        });
-      }
-      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,
-      },
-      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);
-    }
-  }
-
-  _fireCreateRangeComment(side, range) {
-    this.dispatchEvent(new CustomEvent('create-range-comment', {
-      detail: {side, range},
-      composed: true, bubbles: true,
-    }));
-    this._removeActionBox();
-  }
-
-  _handleRangeCommentRequest(e) {
-    e.stopPropagation();
-    if (!this.selectedRange) {
-      throw Error('Selected Range is needed for new range comment!');
-    }
-    const {side, range} = this.selectedRange;
-    this._fireCreateRangeComment(side, range);
-  }
-
-  _removeActionBox() {
-    this.selectedRange = undefined;
-    const actionBox = this.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;
-    }
-    while (el.previousSibling ||
-        !el.parentElement.classList.contains('content')) {
-      if (el.previousSibling) {
-        el = el.previousSibling;
-        offset += this._getLength(el);
-      } else {
-        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.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
new file mode 100644
index 0000000..80eb0df
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -0,0 +1,563 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../gr-selection-action-box/gr-selection-action-box';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-highlight_html';
+import {GrAnnotation} from './gr-annotation';
+import {normalize} from './gr-range-normalizer';
+import {strToClassName} from '../../../utils/dom-util';
+import {customElement, property} from '@polymer/decorators';
+import {Side} from '../../../constants/constants';
+import {CommentRange} from '../../../types/common';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+import {FILE} from '../gr-diff/gr-diff-line';
+
+interface SidedRange {
+  side: Side;
+  range: CommentRange;
+}
+
+interface NormalizedPosition {
+  node: Node | null;
+  side: Side;
+  line: number;
+  column: number;
+}
+
+interface NormalizedRange {
+  start: NormalizedPosition | null;
+  end: NormalizedPosition | null;
+}
+
+// TODO(TS): Replace by GrCommentThread once that is converted.
+interface CommentThreadElement extends HTMLElement {
+  rootId: string;
+}
+
+@customElement('gr-diff-highlight')
+export class GrDiffHighlight extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array, notify: true})
+  commentRanges: SidedRange[] = [];
+
+  @property({type: Boolean})
+  loggedIn?: boolean;
+
+  @property({type: Object})
+  _cachedDiffBuilder?: GrDiffBuilderElement;
+
+  @property({type: Object, notify: true})
+  selectedRange?: SidedRange;
+
+  /** @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 = this.querySelector(
+        'gr-diff-builder'
+      ) as GrDiffBuilderElement;
+    }
+    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 A DOM Selection living in the shadow DOM of
+   * the diff element.
+   * @param 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: Selection | Range | null,
+    isMouseUp: boolean
+  ) {
+    if (selection === null) return;
+    // 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: Event): CommentThreadElement | null {
+    const path = (dom(e) as EventApi).path || [];
+    for (const pathEl of path) {
+      if (
+        pathEl instanceof HTMLElement &&
+        pathEl.classList.contains('comment-thread')
+      ) {
+        return pathEl as CommentThreadElement;
+      }
+    }
+    return null;
+  }
+
+  _toggleRangeElHighlight(
+    threadEl: CommentThreadElement,
+    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.
+    let curNode: HTMLElement | null = threadEl.assignedSlot;
+    while (curNode) {
+      if (curNode.nodeName === 'TABLE') break;
+      curNode = curNode.parentElement;
+    }
+    if (curNode?.querySelectorAll) {
+      if (highlightRange) {
+        const rangeNodes = curNode.querySelectorAll(
+          `.range.${strToClassName(threadEl.rootId)}`
+        );
+        rangeNodes.forEach(rangeNode => {
+          rangeNode.classList.add('rangeHighlight');
+          rangeNode.classList.remove('range');
+        });
+      } else {
+        const rangeNodes = curNode.querySelectorAll(
+          `.rangeHighlight.${strToClassName(threadEl.rootId)}`
+        );
+        rangeNodes.forEach(rangeNode => {
+          rangeNode.classList.remove('rangeHighlight');
+          rangeNode.classList.add('range');
+        });
+      }
+    }
+  }
+
+  _handleCommentThreadMouseenter(e: Event) {
+    const threadEl = this._getThreadEl(e)!;
+    const index = this._indexForThreadEl(threadEl);
+
+    if (index !== undefined) {
+      this.set(['commentRanges', index, 'hovering'], true);
+    }
+
+    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  }
+
+  _handleCommentThreadMouseleave(e: Event) {
+    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: HTMLElement) {
+    const side = threadEl.getAttribute('comment-side') as Side;
+    const rangeString = threadEl.getAttribute('range');
+    if (!rangeString) return undefined;
+    const range = JSON.parse(rangeString) as CommentRange;
+
+    if (!range) return undefined;
+
+    return this._indexOfCommentRange(side, range);
+  }
+
+  _indexOfCommentRange(side: Side, range: CommentRange) {
+    function rangesEqual(a: CommentRange, b: CommentRange) {
+      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).
+   */
+  _getNormalizedRange(selection: Selection | Range) {
+    /* 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 fixed normalized range
+   */
+  _normalizeRange(domRange: Range): NormalizedRange {
+    const range = 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 range Normalized range, ie column/line numbers
+   * @param domRange DOM Range object
+   * @return fixed normalized range
+   */
+  _fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+    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 instanceof HTMLElement &&
+      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 td.content child
+   * @param offset offset within node
+   */
+  _normalizeSelectionSide(
+    node: Node | null,
+    offset: number
+  ): NormalizedPosition | null {
+    let column;
+    if (!node || !this.contains(node)) return null;
+    const lineEl = this.diffBuilder.getLineElByChild(node);
+    if (!lineEl) return null;
+    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    if (!side) return null;
+    const line = this.diffBuilder.getLineNumberByChild(lineEl);
+    if (!line || line === FILE) return null;
+    const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
+    if (!contentTd) return null;
+    const contentText = contentTd.querySelector('.contentText');
+    if (!contentTd.contains(node)) {
+      node = contentText;
+      column = 0;
+    } else {
+      const thread = contentTd.querySelector('.comment-thread');
+      if (thread?.contains(node)) {
+        column = this._getLength(contentText);
+        node = contentText;
+      } else {
+        column = this._convertOffsetToColumn(node, offset);
+      }
+    }
+
+    return {
+      node,
+      side,
+      line,
+      column,
+    };
+  }
+
+  /**
+   * 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: GrSelectionActionBox,
+    startLine: number,
+    range: Text | Element | Range
+  ) {
+    if (startLine > 1) {
+      actionBox.placeAbove(range);
+      return;
+    }
+    actionBox.positionBelow = true;
+    actionBox.placeBelow(range);
+  }
+
+  _isRangeValid(range: NormalizedRange | null) {
+    if (!range || !range.start || !range.start.node || !range.end) {
+      return false;
+    }
+    const start = range.start;
+    const end = range.end;
+    return !(
+      start.side !== end.side ||
+      end.line < start.line ||
+      (start.line === end.line && start.column === end.column)
+    );
+  }
+
+  _handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+    /* 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,
+        });
+      }
+      return;
+    }
+
+    let actionBox = this.shadowRoot!.querySelector(
+      'gr-selection-action-box'
+    ) as GrSelectionActionBox | null;
+    if (!actionBox) {
+      actionBox = document.createElement('gr-selection-action-box');
+      this.root!.insertBefore(actionBox, this.root!.firstElementChild);
+    }
+    this.selectedRange = {
+      range: {
+        start_line: start.line,
+        start_character: start.column,
+        end_line: end.line,
+        end_character: end.column,
+      },
+      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 instanceof HTMLElement &&
+      start.node.classList.contains('content') &&
+      (start.node.firstChild instanceof Element ||
+        start.node.firstChild instanceof Text)
+    ) {
+      this._positionActionBox(actionBox, start.line, start.node.firstChild);
+    } else if (start.node instanceof Element || start.node instanceof Text) {
+      this._positionActionBox(actionBox, start.line, start.node);
+    } else {
+      console.warn('Failed to position comment action box.');
+      this._removeActionBox();
+    }
+  }
+
+  _fireCreateRangeComment(side: Side, range: CommentRange) {
+    this.dispatchEvent(
+      new CustomEvent('create-range-comment', {
+        detail: {side, range},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    this._removeActionBox();
+  }
+
+  _handleRangeCommentRequest(e: Event) {
+    e.stopPropagation();
+    if (!this.selectedRange) {
+      throw Error('Selected Range is needed for new range comment!');
+    }
+    const {side, range} = this.selectedRange;
+    this._fireCreateRangeComment(side, range);
+  }
+
+  _removeActionBox() {
+    this.selectedRange = undefined;
+    const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
+    if (actionBox) {
+      this.root!.removeChild(actionBox);
+    }
+  }
+
+  _convertOffsetToColumn(el: Node, offset: number) {
+    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!;
+      }
+    }
+    return offset;
+  }
+
+  /**
+   * Get length of a node. If the node is a content node, then only give the
+   * length of its .contentText child.
+   *
+   * @param node this is sometimes passed as null.
+   */
+  _getLength(node: Node | null): number {
+    if (node === null) return 0;
+    if (node instanceof Element && node.classList.contains('content')) {
+      return this._getLength(node.querySelector('.contentText')!);
+    } else {
+      return GrAnnotation.getLength(node);
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-highlight': 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
deleted file mode 100644
index 08b21499..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts
new file mode 100644
index 0000000..5a6cb1c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts
@@ -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';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      position: relative;
+    }
+    gr-selection-action-box {
+      /**
+         * Needs z-index to appear above wrapped content, since it's inserted
+         * 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
deleted file mode 100644
index 86f1505..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ /dev/null
@@ -1,630 +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">
-<meta charset="utf-8">
-<title>gr-diff-highlight</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>
-    <style>
-      .tab-indicator:before {
-        color: #C62828;
-        /* >> character */
-        content: '\00BB';
-      }
-    </style>
-    <gr-diff-highlight>
-      <table id="diffTable">
-
-        <tbody class="section both">
-           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div></td>
-            <td class="right lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">	</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-<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>
-            <td class="right lineNum" data-value="119"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="140"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
-                [Yet another random diff thread content here]
-            </div></td>
-            <td class="right lineNum" data-value="120"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">	</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="141"></td>
-            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">	</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-            <td class="right lineNum" data-value="130"></td>
-            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section contextControl">
-          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
-            <td class="left contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-            <td class="right contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta total">
-          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
-            <td class="left"></td>
-            <td class="blank"></td>
-            <td class="right lineNum" data-value="146"></td>
-            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="165"></td>
-            <td class="content both"><div class="contentText"></div></td>
-            <td class="right lineNum" data-value="147"></td>
-            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
-          </tr>
-        </tbody>
-
-      </table>
-    </gr-diff-highlight>
-  </template>
-</test-fixture>
-
-<test-fixture id="highlighted">
-  <template>
-    <div>
-      <hl class="rangeHighlight">foo</hl>
-      bar
-      <hl class="rangeHighlight">baz</hl>
-    </div>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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(() => {
-      contentStubs = null;
-      window.getSelection().removeAllRanges();
-    });
-
-    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);
-    });
-
-    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('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('multiple ranges aka firefox implementation', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-
-      const startRange = document.createRange();
-      startRange.setStart(startContent.firstChild, 10);
-      startRange.setEnd(startContent.firstChild, 11);
-
-      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);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-    });
-
-    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');
-    });
-
-    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('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('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('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('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('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);
-    });
-
-    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);
-    });
-
-    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('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('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('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('_fixTripleClickSelection empty line', () => {
-      const startContent = stubContent(146, 'right');
-      const endContent = stubContent(165, 'left');
-      emulateSelection(startContent.firstChild, 0,
-          endContent.parentElement.previousElementSibling, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 146,
-        start_character: 0,
-        end_line: 146,
-        end_character: 84,
-      });
-      assert.equal(side, 'right');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
new file mode 100644
index 0000000..39d5c2a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
@@ -0,0 +1,606 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-highlight.js';
+import {_getTextOffset} from './gr-range-normalizer.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len */
+const basicFixture = fixtureFromTemplate(html`
+<style>
+      .tab-indicator:before {
+        color: #C62828;
+        /* >> character */
+        content: '\\00BB';
+      }
+    </style>
+    <gr-diff-highlight>
+      <table id="diffTable">
+
+        <tbody class="section both">
+           <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="1"></td>
+            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+            <td class="right lineNum" data-value="1"></td>
+            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+            <td class="left lineNum" data-value="2"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div></td>
+            <td class="right lineNum" data-value="2"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+          </tr>
+        </tbody>
+
+<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>
+            <td class="right lineNum" data-value="119"></td>
+            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+            <td class="left lineNum" data-value="140"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+                [Yet another random diff thread content here]
+            </div></td>
+            <td class="right lineNum" data-value="120"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="141"></td>
+            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">\u0009</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+            <td class="right lineNum" data-value="130"></td>
+            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section contextControl">
+          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
+            <td class="left contextLineNum"></td>
+            <td>
+              <gr-button>+10↑</gr-button>
+              -
+              <gr-button>Show 21 common lines</gr-button>
+              -
+              <gr-button>+10↓</gr-button>
+            </td>
+            <td class="right contextLineNum"></td>
+            <td>
+              <gr-button>+10↑</gr-button>
+              -
+              <gr-button>Show 21 common lines</gr-button>
+              -
+              <gr-button>+10↓</gr-button>
+            </td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta total">
+          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+            <td class="left"></td>
+            <td class="blank"></td>
+            <td class="right lineNum" data-value="146"></td>
+            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="165"></td>
+            <td class="content both"><div class="contentText"></div></td>
+            <td class="right lineNum" data-value="147"></td>
+            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+          </tr>
+        </tbody>
+
+      </table>
+    </gr-diff-highlight>
+`);
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate()[1];
+  });
+
+  suite('comment events', () => {
+    let builder;
+
+    setup(() => {
+      builder = {
+        getContentsByLineRange: sinon.stub().returns([]),
+        getLineElByChild: sinon.stub().returns({}),
+        getSideByLineEl: sinon.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'}];
+
+      sinon.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,
+      }}];
+
+      sinon.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'}];
+
+      sinon.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`, () => {
+      sinon.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.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
+      builder.getContentTdByLine.withArgs(line, side).returns(contentTd);
+      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: sinon.stub(),
+        placeBelow: sinon.stub(),
+      });
+      diff = element.querySelector('#diffTable');
+      builder = {
+        getContentTdByLine: sinon.stub(),
+        getContentTdByLineEl: sinon.stub(),
+        getLineElByChild,
+        getLineNumberByChild: sinon.stub(),
+        getSideByLineEl: sinon.stub(),
+      };
+      element._cachedDiffBuilder = builder;
+    });
+
+    teardown(() => {
+      contentStubs = null;
+      window.getSelection().removeAllRanges();
+    });
+
+    test('single first line', () => {
+      const content = stubContent(1, 'right');
+      sinon.spy(element, '_positionActionBox');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, 'right');
+      const endContent = stubContent(2, 'right');
+      sinon.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');
+      sinon.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('multiline', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      sinon.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('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
+
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
+
+      const getRangeAtStub = sinon.stub();
+      getRangeAtStub
+          .onFirstCall().returns(startRange)
+          .onSecondCall()
+          .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sinon.stub(),
+      };
+      element._handleSelection(selection);
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+    });
+
+    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');
+    });
+
+    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('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('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('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('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('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);
+    });
+
+    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);
+    });
+
+    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('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('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('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 = _getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, 'right');
+      child = content.lastChild;
+      result = _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('_fixTripleClickSelection empty line', () => {
+      const startContent = stubContent(146, 'right');
+      const endContent = stubContent(165, 'left');
+      emulateSelection(startContent.firstChild, 0,
+          endContent.parentElement.previousElementSibling, 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 146,
+        start_character: 0,
+        end_line: 146,
+        end_character: 84,
+      });
+      assert.equal(side, 'right');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
deleted file mode 100644
index 5d04bd7..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
+++ /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.
- */
-
-// 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,
-    };
-  },
-
-  _getContentTextParent(target) {
-    let element = target;
-    if (element.nodeName === '#text') {
-      element = element.parentElement;
-    }
-    while (element && !element.classList.contains('contentText')) {
-      if (element.parentElement === null) {
-        return target;
-      }
-      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;
-      }
-      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);
-      }
-    }
-    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;
-  },
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts
new file mode 100644
index 0000000..469c24a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts
@@ -0,0 +1,114 @@
+/**
+ * @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.
+ */
+
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+export interface NormalizedRange {
+  endContainer: Node;
+  endOffset: number;
+  startContainer: Node;
+  startOffset: number;
+}
+
+/**
+ * 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 range - the standard DOM selector range.
+ * @return A modified version of the range that correctly accounts
+ *     for syntax highlighting.
+ */
+export function normalize(range: Range): NormalizedRange {
+  const startContainer = _getContentTextParent(range.startContainer);
+  const startOffset =
+    range.startOffset + _getTextOffset(startContainer, range.startContainer);
+  const endContainer = _getContentTextParent(range.endContainer);
+  const endOffset =
+    range.endOffset + _getTextOffset(endContainer, range.endContainer);
+  return {
+    startContainer,
+    startOffset,
+    endContainer,
+    endOffset,
+  };
+}
+
+function _getContentTextParent(target: Node): Node {
+  if (!target.parentElement) return target;
+
+  let element: Element | null;
+  if (target instanceof Element) {
+    element = target;
+  } else {
+    element = target.parentElement;
+  }
+
+  while (element && !element.classList.contains('contentText')) {
+    if (element.parentElement === null) {
+      return target;
+    }
+    element = element.parentElement;
+  }
+  return element ? element : target;
+}
+
+/**
+ * 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 node The root DOM element to be searched through.
+ * @param child The child element being searched for.
+ */
+// TODO(TS): Only export for test.
+export function _getTextOffset(node: Node | null, child: Node): number {
+  let count = 0;
+  let stack = [node];
+  while (stack.length) {
+    const n = stack.pop();
+    if (n === child) {
+      break;
+    }
+    if (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 += _getLength(n);
+    }
+  }
+  return count;
+}
+
+/**
+ * The DOM API textContent.length calculation is broken when the text
+ * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+ *
+ * @param node A text node.
+ * @return The length of the text.
+ */
+function _getLength(node?: Node | null) {
+  return node && node.textContent
+    ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
+    : 0;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
deleted file mode 100644
index 2ed69b6..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ /dev/null
@@ -1,1126 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 MSG_EMPTY_BLAME = 'No blame information for this diff.';
-
-const EVENT_AGAINST_PARENT = 'diff-against-parent';
-const EVENT_ZERO_REBASE = 'rebase-percent-zero';
-const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
-/** @enum {string} */
-const TimingLabel = {
-  TOTAL: 'Diff Total Render',
-  CONTENT: 'Diff Content Render',
-  SYNTAX: 'Diff Syntax Render',
-};
-
-// Disable syntax highlighting if the overall diff is too large.
-const SYNTAX_MAX_DIFF_LENGTH = 20000;
-
-// 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;
-
-// 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'; }
-  /**
-   * 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 saved or discarded
-   *
-   * @event diff-comments-modified
-   */
-
-  static get properties() {
-    return {
-      changeNum: String,
-      noAutoRender: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      patchRange: Object,
-      path: String,
-      prefs: {
-        type: Object,
-      },
-      projectName: String,
-      displayLine: {
-        type: Boolean,
-        value: false,
-      },
-      isImageDiff: {
-        type: Boolean,
-        computed: '_computeIsImageDiff(diff)',
-        notify: true,
-      },
-      commitRange: Object,
-      filesWeblinks: {
-        type: Object,
-        value() {
-          return {};
-        },
-        notify: true,
-      },
-      hidden: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      noRenderOnPrefsChange: {
-        type: Boolean,
-        value: false,
-      },
-      comments: {
-        type: Object,
-        observer: '_commentsChanged',
-      },
-      lineWrapping: {
-        type: Boolean,
-        value: false,
-      },
-      viewMode: {
-        type: String,
-        value: DiffViewMode.SIDE_BY_SIDE,
-      },
-
-      /**
-       * Special line number which should not be collapsed into a shared region.
-       *
-       * @type {{
-       *  number: number,
-       *  leftSide: {boolean}
-       * }|null}
-       */
-      lineOfInterest: Object,
-
-      /**
-       * If the diff fails to load, show the failure message in the diff rather
-       * than bubbling the error up to the whole page. This is useful for when
-       * loading inline diffs because one diff failing need not mark the whole
-       * page with a failure.
-       */
-      showLoadFailure: Boolean,
-
-      isBlameLoaded: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeIsBlameLoaded(_blame)',
-      },
-
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-
-      _loading: {
-        type: Boolean,
-        value: false,
-      },
-
-      /** @type {?string} */
-      _errorMessage: {
-        type: String,
-        value: null,
-      },
-
-      /** @type {?Object} */
-      _baseImage: Object,
-      /** @type {?Object} */
-      _revisionImage: Object,
-      /**
-       * This is a DiffInfo object.
-       */
-      diff: {
-        type: Object,
-        notify: true,
-      },
-
-      /** @type {?Object} */
-      _blame: {
-        type: Object,
-        value: null,
-      },
-
-      /**
-       * @type {!Array<!Gerrit.CoverageRange>}
-       */
-      _coverageRanges: {
-        type: Array,
-        value: () => [],
-      },
-
-      _loadedWhitespaceLevel: String,
-
-      _parentIndex: {
-        type: Number,
-        computed: '_computeParentIndex(patchRange.*)',
-      },
-
-      _syntaxHighlightingEnabled: {
-        type: Boolean,
-        computed:
-        '_isSyntaxHighlightingEnabled(prefs.*, diff)',
-      },
-
-      _layers: {
-        type: Array,
-        value: [],
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
-        ' noRenderOnPrefsChange)',
-      '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
-    ];
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener(
-        // These are named inconsistently for a reason:
-        // The create-comment event is fired to indicate that we should
-        // create a comment.
-        // The comment-* events are just notifying that the comments did already
-        // change in some way, and that we should update any models we may want
-        // to keep in sync.
-        'create-comment',
-        e => this._handleCreateComment(e));
-    this.addEventListener('comment-discard',
-        e => this._handleCommentDiscard(e));
-    this.addEventListener('comment-update',
-        e => this._handleCommentUpdate(e));
-    this.addEventListener('comment-save',
-        e => this._handleCommentSave(e));
-    this.addEventListener('render-start',
-        () => this._handleRenderStart());
-    this.addEventListener('render-content',
-        () => this._handleRenderContent());
-    this.addEventListener('normalize-range',
-        event => this._handleNormalizeRange(event));
-    this.addEventListener('diff-context-expanded',
-        event => this._handleDiffContextExpanded(event));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    if (this._canReload()) {
-      this.reload();
-    }
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-  }
-
-  /**
-   * @param {boolean=} 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();
-
-    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;
-
-    if (shouldReportMetric) {
-      // We listen on render viewport only on DiffPage (on paramsChanged)
-      this._listenToViewportRender();
-    }
-
-    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;
-        });
-
-    const assetRequest = diffRequest.then(diff => {
-      // If the diff is null, then it's failed to load.
-      if (!diff) { return null; }
-
-      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();
-                });
-              } 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; });
-  }
-
-  _getCoverageData() {
-    const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
-    this.$.jsAPI.getCoverageAnnotationApi().
-        then(coverageAnnotationApi => {
-          if (!coverageAnnotationApi) return;
-          const provider = coverageAnnotationApi.getCoverageProvider();
-          return provider(changeNum, path, basePatchNum, patchNum)
-              .then(coverageRanges => {
-                if (!coverageRanges ||
-                  changeNum !== this.changeNum ||
-                  path !== this.path ||
-                  basePatchNum !== this.patchRange.basePatchNum ||
-                  patchNum !== this.patchRange.patchNum) {
-                  return;
-                }
-
-                const existingCoverageRanges = this._coverageRanges;
-                this._coverageRanges = coverageRanges;
-
-                // Notify with existing coverage ranges
-                // in case there is some existing coverage data that needs to be removed
-                existingCoverageRanges.forEach(range => {
-                  coverageAnnotationApi.notify(
-                      path,
-                      range.code_range.start_line,
-                      range.code_range.end_line,
-                      range.side);
-                });
-
-                // Notify with new coverage data
-                coverageRanges.forEach(range => {
-                  coverageAnnotationApi.notify(
-                      path,
-                      range.code_range.start_line,
-                      range.code_range.end_line,
-                      range.side);
-                });
-              });
-        })
-        .catch(err => {
-          console.warn('Loading coverage ranges failed: ', err);
-        });
-  }
-
-  _getFilesWeblinks(diff) {
-    if (!this.commitRange) {
-      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}),
-    };
-  }
-
-  /** Cancel any remaining diff builder rendering work. */
-  cancel() {
-    this.$.diff.cancel();
-  }
-
-  /** @return {!Array<!HTMLElement>} */
-  getCursorStops() {
-    return this.$.diff.getCursorStops();
-  }
-
-  /** @return {boolean} */
-  isRangeSelected() {
-    return this.$.diff.isRangeSelected();
-  }
-
-  createRangeComment() {
-    return this.$.diff.createRangeComment();
-  }
-
-  toggleLeftDiff() {
-    this.$.diff.toggleLeftDiff();
-  }
-
-  /**
-   * Load and display blame information for the base of the diff.
-   *
-   * @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);
-          }
-
-          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(
-        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.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 {
-        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;
-      });
-    } else {
-      this._baseImage = null;
-      this._revisionImage = null;
-      return Promise.resolve();
-    }
-  }
-
-  /**
-   * @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;
-  }
-
-  /**
-   * @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._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.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
new file mode 100644
index 0000000..c6f5d21
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -0,0 +1,1227 @@
+/**
+ * @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.
+ */
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-comment-thread/gr-comment-thread';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../gr-diff/gr-diff';
+import '../gr-syntax-layer/gr-syntax-layer';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-host_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {rangesEqual} from '../gr-diff/gr-diff-utils';
+import {appContext} from '../../../services/app-context';
+import {
+  getParentIndex,
+  isMergeParent,
+  isNumber,
+} from '../../../utils/patch-set-util';
+import {
+  Comment,
+  isDraft,
+  sortComments,
+  UIComment,
+} from '../../../utils/comment-util';
+import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+  CommitRange,
+  CoverageRange,
+  DiffLayer,
+  DiffLayerListener,
+} from '../../../types/types';
+import {
+  Base64ImageFile,
+  BlameInfo,
+  ChangeInfo,
+  CommentRange,
+  DiffInfo,
+  DiffPreferencesInfo,
+  NumericChangeId,
+  PatchRange,
+  PatchSetNum,
+  RepoName,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {GrDiff, LineOfInterest} from '../gr-diff/gr-diff';
+import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
+import {
+  DiffViewMode,
+  IgnoreWhitespaceType,
+  Side,
+} from '../../../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+import {LineNumber} from '../gr-diff/gr-diff-line';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {PatchSetFile} from '../../../types/types';
+import {KnownExperimentId} from '../../../services/flags/flags';
+
+const MSG_EMPTY_BLAME = 'No blame information for this diff.';
+
+const EVENT_AGAINST_PARENT = 'diff-against-parent';
+const EVENT_ZERO_REBASE = 'rebase-percent-zero';
+const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
+
+const TimingLabel = {
+  TOTAL: 'Diff Total Render',
+  CONTENT: 'Diff Content Render',
+  SYNTAX: 'Diff Syntax Render',
+};
+
+// Disable syntax highlighting if the overall diff is too large.
+const SYNTAX_MAX_DIFF_LENGTH = 20000;
+
+// 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;
+
+// 120 lines is good enough threshold for full-sized window viewport
+const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
+
+function isImageDiff(diff?: DiffInfo) {
+  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));
+}
+
+interface LineInfo {
+  beforeNumber?: LineNumber;
+  afterNumber?: LineNumber;
+}
+
+// TODO(TS): Consolidate this with the CommentThread interface of comment-api.
+// What is being used here is just a local object for collecting all the data
+// that is needed to create a GrCommentThread component, see
+// _createThreadElement().
+interface CommentThread {
+  comments: UIComment[];
+  // In the context of a diff each thread must have a side!
+  commentSide: Side;
+  patchNum?: PatchSetNum;
+  lineNum?: LineNumber;
+  isOnParent?: boolean;
+  range?: CommentRange;
+}
+
+export interface GrDiffHost {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: JsApiService & Element;
+    syntaxLayer: GrSyntaxLayer & Element;
+    diff: GrDiff;
+  };
+}
+
+/**
+ * Wrapper around gr-diff.
+ *
+ * Webcomponent fetching diffs and related data from restAPI and passing them
+ * to the presentational gr-diff for rendering. <gr-diff-host> is a Gerrit
+ * specific component, while <gr-diff> is a re-usable component.
+ */
+@customElement('gr-diff-host')
+export class GrDiffHost extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * 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 saved or discarded
+   *
+   * @event diff-comments-modified
+   */
+
+  @property({type: Number})
+  changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Boolean})
+  noAutoRender = false;
+
+  @property({type: Object})
+  patchRange?: PatchRange;
+
+  @property({type: Object})
+  file?: PatchSetFile;
+
+  @property({type: String})
+  path?: string;
+
+  @property({type: Object})
+  prefs?: DiffPreferencesInfo;
+
+  @property({type: String})
+  projectName?: RepoName;
+
+  @property({type: Boolean})
+  displayLine = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeIsImageDiff(diff)',
+    notify: true,
+  })
+  isImageDiff?: boolean;
+
+  @property({type: Object})
+  commitRange?: CommitRange;
+
+  @property({type: Object, notify: true})
+  filesWeblinks: FilesWebLinks | {} = {};
+
+  @property({type: Boolean, reflectToAttribute: true})
+  hidden = false;
+
+  @property({type: Boolean})
+  noRenderOnPrefsChange = false;
+
+  @property({type: Object, observer: '_commentsChanged'})
+  comments?: TwoSidesComments;
+
+  @property({type: Boolean})
+  lineWrapping = false;
+
+  @property({type: String})
+  viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  @property({type: Object})
+  lineOfInterest?: LineOfInterest;
+
+  @property({type: Boolean})
+  showLoadFailure?: boolean;
+
+  @property({
+    type: Boolean,
+    notify: true,
+    computed: '_computeIsBlameLoaded(_blame)',
+  })
+  isBlameLoaded?: boolean;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: String})
+  _errorMessage: string | null = null;
+
+  @property({type: Object})
+  _baseImage: Base64ImageFile | null = null;
+
+  @property({type: Object})
+  _revisionImage: Base64ImageFile | null = null;
+
+  @property({type: Object, notify: true})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  _fetchDiffPromise: Promise<DiffInfo> | null = null;
+
+  @property({type: Object})
+  _blame: BlameInfo[] | null = null;
+
+  @property({type: Array})
+  _coverageRanges: CoverageRange[] = [];
+
+  @property({type: String})
+  _loadedWhitespaceLevel?: IgnoreWhitespaceType;
+
+  @property({type: Number, computed: '_computeParentIndex(patchRange.*)'})
+  _parentIndex: number | null = null;
+
+  @property({
+    type: Boolean,
+    computed: '_isSyntaxHighlightingEnabled(prefs.*, diff)',
+  })
+  _syntaxHighlightingEnabled?: boolean;
+
+  @property({type: Array})
+  _layers: DiffLayer[] = [];
+
+  private readonly reporting = appContext.reportingService;
+
+  private readonly flags = appContext.flagsService;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener(
+      // These are named inconsistently for a reason:
+      // The create-comment event is fired to indicate that we should
+      // create a comment.
+      // The comment-* events are just notifying that the comments did already
+      // change in some way, and that we should update any models we may want
+      // to keep in sync.
+      'create-comment',
+      e => this._handleCreateComment(e)
+    );
+    this.addEventListener('comment-discard', e =>
+      this._handleCommentDiscard(e)
+    );
+    this.addEventListener('comment-update', e => this._handleCommentUpdate(e));
+    this.addEventListener('comment-save', e => this._handleCommentSave(e));
+    this.addEventListener('render-start', () => this._handleRenderStart());
+    this.addEventListener('render-content', () => this._handleRenderContent());
+    this.addEventListener('normalize-range', event =>
+      this._handleNormalizeRange(event)
+    );
+    this.addEventListener('diff-context-expanded', event =>
+      this._handleDiffContextExpanded(event)
+    );
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    if (this._canReload()) {
+      this.reload();
+    }
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.clear();
+  }
+
+  /**
+   * @param shouldReportMetric indicate a new Diff Page. This is a
+   * signal to report metrics event that started on location change.
+   * @return
+   */
+  async reload(shouldReportMetric?: boolean) {
+    this.clear();
+    if (!this.path) throw new Error('Missing required "path" property.');
+    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+    this.diff = undefined;
+    this._errorMessage = null;
+    const whitespaceLevel = this._getIgnoreWhitespace();
+
+    this._layers = this._getLayers(this.path, this.changeNum);
+
+    if (shouldReportMetric) {
+      // We listen on render viewport only on DiffPage (on paramsChanged)
+      this._listenToViewportRender();
+    }
+
+    this._coverageRanges = [];
+    this._getCoverageData();
+
+    try {
+      const diff = await this._getDiff();
+      this._loadedWhitespaceLevel = whitespaceLevel;
+      this._reportDiff(diff);
+
+      await this._loadDiffAssets(diff);
+
+      // Not waiting for coverage ranges intentionally as
+      // plugin loading should not block the content rendering
+
+      this.filesWeblinks = this._getFilesWeblinks(diff);
+      this.diff = diff;
+      const event = await this._onRenderOnce();
+      if (shouldReportMetric) {
+        // We report diffViewContentDisplayed only on reload caused
+        // by params changed - expected only on Diff Page.
+        this.reporting.diffViewContentDisplayed();
+      }
+      const needsSyntaxHighlighting = !!event.detail?.contentRendered;
+      if (needsSyntaxHighlighting) {
+        this.reporting.time(TimingLabel.SYNTAX);
+        try {
+          await this.$.syntaxLayer.process();
+        } finally {
+          this.reporting.timeEnd(TimingLabel.SYNTAX);
+        }
+      }
+    } catch (e) {
+      if (e instanceof Response) {
+        this._handleGetDiffError(e);
+      } else {
+        console.warn('Error encountered loading diff:', e);
+      }
+    } finally {
+      this.reporting.timeEnd(TimingLabel.TOTAL);
+    }
+  }
+
+  private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
+    // Get layers from plugins (if any).
+    return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)];
+  }
+
+  private _onRenderOnce(): Promise<CustomEvent> {
+    return new Promise<CustomEvent>(resolve => {
+      const callback = (event: CustomEvent) => {
+        this.removeEventListener('render', callback);
+        resolve(event);
+      };
+      this.addEventListener('render', callback);
+    });
+  }
+
+  clear() {
+    if (this.path) this.$.jsAPI.disposeDiffLayers(this.path);
+    this._layers = [];
+  }
+
+  _getCoverageData() {
+    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+    if (!this.change) throw new Error('Missing required "change" prop.');
+    if (!this.path) throw new Error('Missing required "path" prop.');
+    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    const changeNum = this.changeNum;
+    const change = this.change;
+    const path = this.path;
+    // Coverage providers do not provide data for EDIT and PARENT patch sets.
+
+    const toNumberOnly = (patchNum: PatchSetNum) =>
+      isNumber(patchNum) ? patchNum : undefined;
+
+    const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
+    const patchNum = toNumberOnly(this.patchRange.patchNum);
+    this.$.jsAPI
+      .getCoverageAnnotationApis()
+      .then(coverageAnnotationApis => {
+        coverageAnnotationApis.forEach(coverageAnnotationApi => {
+          const provider = coverageAnnotationApi.getCoverageProvider();
+          if (!provider) return;
+          provider(changeNum, path, basePatchNum, patchNum, change)
+            .then(coverageRanges => {
+              if (!this.patchRange) throw new Error('Missing "patchRange".');
+              if (
+                !coverageRanges ||
+                changeNum !== this.changeNum ||
+                change !== this.change ||
+                path !== this.path ||
+                basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
+                patchNum !== toNumberOnly(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('Applying coverage from provider failed: ', err);
+            });
+        });
+      })
+      .catch(err => {
+        console.warn('Loading coverage ranges failed: ', err);
+      });
+  }
+
+  _getFilesWeblinks(diff: DiffInfo) {
+    if (!this.projectName || !this.commitRange || !this.path) 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}
+      ),
+    };
+  }
+
+  /** Cancel any remaining diff builder rendering work. */
+  cancel() {
+    this.$.diff.cancel();
+    this.$.syntaxLayer.cancel();
+  }
+
+  getCursorStops() {
+    return this.$.diff.getCursorStops();
+  }
+
+  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.
+   */
+  loadBlame(): Promise<BlameInfo[]> {
+    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    if (!this.path) throw new Error('Missing required "path" property.');
+    return this.$.restAPI
+      .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
+      .then(blame => {
+        if (!blame || !blame.length) {
+          this.dispatchEvent(
+            new CustomEvent('show-alert', {
+              detail: {message: MSG_EMPTY_BLAME},
+              composed: true,
+              bubbles: true,
+            })
+          );
+          return Promise.reject(MSG_EMPTY_BLAME);
+        }
+
+        this._blame = blame;
+        return blame;
+      });
+  }
+
+  clearBlame() {
+    this._blame = null;
+  }
+
+  getThreadEls(): GrCommentThread[] {
+    return Array.from(this.$.diff.querySelectorAll('.comment-thread'));
+  }
+
+  addDraftAtLine(el: Element) {
+    this.$.diff.addDraftAtLine(el);
+  }
+
+  clearDiffContent() {
+    this.$.diff.clearDiffContent();
+  }
+
+  expandAllContext() {
+    this.$.diff.expandAllContext();
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _canReload() {
+    return (
+      !!this.changeNum && !!this.patchRange && !!this.path && !this.noAutoRender
+    );
+  }
+
+  // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
+  prefetchDiff() {
+    if (
+      !!this.changeNum &&
+      !!this.patchRange &&
+      !!this.path &&
+      this._fetchDiffPromise === null
+    ) {
+      this._fetchDiffPromise = this._getDiff();
+    }
+  }
+
+  _getDiff(): Promise<DiffInfo> {
+    if (this._fetchDiffPromise !== null) {
+      const fetchDiffPromise = this._fetchDiffPromise;
+      this._fetchDiffPromise = null;
+      return fetchDiffPromise;
+    }
+    // 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) => {
+      if (!this.changeNum) throw new Error('Missing required "changeNum".');
+      if (!this.patchRange) throw new Error('Missing required "patchRange".');
+      if (!this.path) throw new Error('Missing required "path" property.');
+      this.$.restAPI
+        .getDiff(
+          this.changeNum,
+          this.patchRange.basePatchNum,
+          this.patchRange.patchNum,
+          this.path,
+          this._getIgnoreWhitespace(),
+          reject
+        )
+        .then(resolve);
+    });
+  }
+
+  _handleGetDiffError(response: 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?: DiffInfo) {
+    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) throw new Error('Missing required "patchRange".');
+    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,
+      });
+    }
+  }
+
+  _loadDiffAssets(diff?: DiffInfo) {
+    if (isImageDiff(diff)) {
+      // diff! is justified, because isImageDiff() returns false otherwise
+      return this._getImages(diff!).then(images => {
+        this._baseImage = images.baseImage;
+        this._revisionImage = images.revisionImage;
+      });
+    } else {
+      this._baseImage = null;
+      this._revisionImage = null;
+      return Promise.resolve();
+    }
+  }
+
+  _computeIsImageDiff(diff?: DiffInfo) {
+    return isImageDiff(diff);
+  }
+
+  _commentsChanged(newComments: TwoSidesComments) {
+    const allComments = [];
+    for (const side of [Side.LEFT, 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);
+    }
+  }
+
+  _createThreads(comments: UIComment[]): CommentThread[] {
+    const sortedComments = 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.
+      if (!comment.__commentSide) throw new Error('Missing "__commentSide".');
+      const newThread: CommentThread = {
+        comments: [comment],
+        commentSide: comment.__commentSide,
+        patchNum: comment.patch_set,
+        lineNum: comment.line,
+        isOnParent: comment.side === 'PARENT',
+      };
+      if (comment.range) {
+        newThread.range = {...comment.range};
+      }
+      threads.push(newThread);
+    }
+    return threads;
+  }
+
+  _computeIsBlameLoaded(blame: BlameInfo[] | null) {
+    return !!blame;
+  }
+
+  _getImages(diff: DiffInfo) {
+    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    return this.$.restAPI.getImagesForDiff(
+      this.changeNum,
+      diff,
+      this.patchRange
+    );
+  }
+
+  _handleCreateComment(e: CustomEvent) {
+    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.
+   */
+  _getOrCreateThread(
+    patchNum: PatchSetNum,
+    lineNum: LineNumber | undefined,
+    commentSide: Side,
+    range?: CommentRange,
+    isOnParent?: boolean
+  ): GrCommentThread {
+    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: Element) {
+    this.$.diff.appendChild(threadEl);
+  }
+
+  _clearThreads() {
+    for (const threadEl of this.getThreadEls()) {
+      const parent = threadEl.parentNode;
+      if (parent) parent.removeChild(threadEl);
+    }
+  }
+
+  _createThreadElement(thread: CommentThread) {
+    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;
+    // Use path before renmaing when comment added on the left when comparing
+    // two patch sets (not against base)
+    if (
+      this.file &&
+      this.file.basePath &&
+      thread.commentSide === Side.LEFT &&
+      !thread.isOnParent
+    ) {
+      threadEl.path = this.file.basePath;
+    } else {
+      threadEl.path = this.path;
+    }
+    threadEl.changeNum = this.changeNum;
+    threadEl.patchNum = thread.patchNum;
+    threadEl.showPatchset = false;
+    // GrCommentThread does not understand 'FILE', but requires undefined.
+    threadEl.lineNum = thread.lineNum !== 'FILE' ? thread.lineNum : undefined;
+    threadEl.projectName = this.projectName;
+    threadEl.range = thread.range;
+    const threadDiscardListener = (e: Event) => {
+      const threadEl = e.currentTarget as Element;
+      const parent = threadEl.parentNode;
+      if (parent) parent.removeChild(threadEl);
+      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.
+   */
+  _getThreadEl(
+    lineNum: LineNumber | undefined,
+    commentSide: Side,
+    range?: CommentRange
+  ): GrCommentThread | null {
+    let line: LineInfo;
+    if (commentSide === Side.LEFT) {
+      line = {beforeNumber: lineNum};
+    } else if (commentSide === Side.RIGHT) {
+      line = {afterNumber: lineNum};
+    } else {
+      throw new Error(`Unknown side: ${commentSide}`);
+    }
+    function matchesRange(threadEl: GrCommentThread) {
+      const rangeAtt = threadEl.getAttribute('range');
+      const threadRange = rangeAtt
+        ? (JSON.parse(rangeAtt) as CommentRange)
+        : undefined;
+      return rangesEqual(threadRange, range);
+    }
+
+    const filteredThreadEls = this._filterThreadElsForLocation(
+      this.getThreadEls(),
+      line,
+      commentSide
+    ).filter(matchesRange);
+    return filteredThreadEls.length ? filteredThreadEls[0] : null;
+  }
+
+  _filterThreadElsForLocation(
+    threadEls: GrCommentThread[],
+    lineInfo: LineInfo,
+    side: Side
+  ) {
+    function matchesLeftLine(threadEl: GrCommentThread) {
+      return (
+        threadEl.getAttribute('comment-side') === Side.LEFT &&
+        threadEl.getAttribute('line-num') === String(lineInfo.beforeNumber)
+      );
+    }
+    function matchesRightLine(threadEl: GrCommentThread) {
+      return (
+        threadEl.getAttribute('comment-side') === Side.RIGHT &&
+        threadEl.getAttribute('line-num') === String(lineInfo.afterNumber)
+      );
+    }
+    function matchesFileComment(threadEl: GrCommentThread) {
+      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: ((thread: GrCommentThread) => boolean)[] = [];
+    if (side !== Side.RIGHT) {
+      matchers.push(matchesLeftLine);
+    }
+    if (side !== Side.LEFT) {
+      matchers.push(matchesRightLine);
+    }
+    if (lineInfo.afterNumber === 'FILE' || lineInfo.beforeNumber === 'FILE') {
+      matchers.push(matchesFileComment);
+    }
+    return threadEls.filter(threadEl =>
+      matchers.some(matcher => matcher(threadEl))
+    );
+  }
+
+  _getIgnoreWhitespace(): IgnoreWhitespaceType {
+    if (!this.prefs || !this.prefs.ignore_whitespace) {
+      return IgnoreWhitespaceType.IGNORE_NONE;
+    }
+    return this.prefs.ignore_whitespace;
+  }
+
+  @observe(
+    'prefs.ignore_whitespace',
+    '_loadedWhitespaceLevel',
+    'noRenderOnPrefsChange'
+  )
+  _whitespaceChanged(
+    preferredWhitespaceLevel?: IgnoreWhitespaceType,
+    loadedWhitespaceLevel?: IgnoreWhitespaceType,
+    noRenderOnPrefsChange?: boolean
+  ) {
+    if (preferredWhitespaceLevel === undefined) return;
+    if (loadedWhitespaceLevel === undefined) return;
+    if (noRenderOnPrefsChange === undefined) return;
+
+    this._fetchDiffPromise = null;
+    if (
+      preferredWhitespaceLevel !== loadedWhitespaceLevel &&
+      !noRenderOnPrefsChange
+    ) {
+      this.reload();
+    }
+  }
+
+  @observe('noRenderOnPrefsChange', 'prefs.*')
+  _syntaxHighlightingChanged(
+    noRenderOnPrefsChange?: boolean,
+    prefsChangeRecord?: PolymerDeepPropertyChange<
+      DiffPreferencesInfo,
+      DiffPreferencesInfo
+    >
+  ) {
+    if (noRenderOnPrefsChange === undefined) return;
+    if (prefsChangeRecord === undefined) return;
+    if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') return;
+
+    if (!noRenderOnPrefsChange) this.reload();
+  }
+
+  _computeParentIndex(
+    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+  ) {
+    if (!patchRangeRecord.base) return null;
+    return isMergeParent(patchRangeRecord.base.basePatchNum)
+      ? getParentIndex(patchRangeRecord.base.basePatchNum)
+      : null;
+  }
+
+  _handleCommentSave(e: CustomEvent) {
+    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: CustomEvent) {
+    const comment = e.detail.comment;
+    this._removeComment(comment);
+    this._handleCommentSaveOrDiscard();
+  }
+
+  _handleCommentUpdate(e: CustomEvent) {
+    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: UIComment) {
+    const side = comment.__commentSide;
+    if (!side) throw new Error('Missing required "side" in comment.');
+    this._removeCommentFromSide(comment, side);
+  }
+
+  _removeCommentFromSide(comment: Comment, side: Side) {
+    let idx = this._findCommentIndex(comment, side);
+    if (idx === -1) {
+      idx = this._findDraftIndex(comment, side);
+    }
+    if (idx !== -1) {
+      this.splice('comments.' + side, idx, 1);
+    }
+  }
+
+  _findCommentIndex(comment: Comment, side: Side) {
+    if (!comment.id || !this.comments || !this.comments[side]) {
+      return -1;
+    }
+    return this.comments[side].findIndex(item => item.id === comment.id);
+  }
+
+  _findDraftIndex(comment: Comment, side: Side) {
+    if (
+      !isDraft(comment) ||
+      !comment.__draftID ||
+      !this.comments ||
+      !this.comments[side]
+    ) {
+      return -1;
+    }
+    return this.comments[side].findIndex(
+      item => isDraft(item) && item.__draftID === comment.__draftID
+    );
+  }
+
+  _isSyntaxHighlightingEnabled(
+    preferenceChangeRecord?: PolymerDeepPropertyChange<
+      DiffPreferencesInfo,
+      DiffPreferencesInfo
+    >,
+    diff?: DiffInfo
+  ) {
+    if (
+      !preferenceChangeRecord ||
+      !preferenceChangeRecord.base ||
+      !preferenceChangeRecord.base.syntax_highlighting ||
+      !diff
+    ) {
+      return false;
+    }
+    return (
+      !this._anyLineTooLong(diff) &&
+      this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH
+    );
+  }
+
+  /**
+   * @return whether any of the lines in diff are longer
+   * than SYNTAX_MAX_LINE_LENGTH.
+   */
+  _anyLineTooLong(diff?: DiffInfo) {
+    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: DiffLayerListener = 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: CustomEvent) {
+    this.reporting.reportInteraction('normalize-range', {
+      side: event.detail.side,
+      lineNum: event.detail.lineNum,
+    });
+  }
+
+  _handleDiffContextExpanded(event: CustomEvent) {
+    this.reporting.reportInteraction('diff-context-expanded', {
+      numLines: event.detail.numLines,
+    });
+  }
+
+  /**
+   * Find the last chunk for the given side.
+   *
+   * @param leftSide true if checking the base of the diff,
+   * false if testing the revision.
+   * @return returns the chunk object or null if there was
+   * no chunk for that side.
+   */
+  _lastChunkForSide(diff: DiffInfo | undefined, leftSide: boolean) {
+    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 leftSide true if checking the base of the diff,
+   * false if testing the revision.
+   * @return 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: DiffInfo | undefined, leftSide: boolean) {
+    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;
+    }
+    if (!lines) return null;
+    return lines[lines.length - 1] === '';
+  }
+
+  _showNewlineWarningLeft(diff?: DiffInfo) {
+    return this._hasTrailingNewlines(diff, true) === false;
+  }
+
+  _showNewlineWarningRight(diff?: DiffInfo) {
+    return this._hasTrailingNewlines(diff, false) === false;
+  }
+
+  _useNewContextControls() {
+    return this.flags.isEnabled(KnownExperimentId.NEW_CONTEXT_CONTROLS);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-host': GrDiffHost;
+  }
+}
+
+// TODO(TS): Be more specific than CustomEvent, which has detail:any.
+declare global {
+  interface HTMLElementEventMap {
+    render: CustomEvent;
+    'normalize-range': CustomEvent;
+    'diff-context-expanded': CustomEvent;
+    'create-comment': CustomEvent;
+    'comment-discard': CustomEvent;
+    'comment-update': CustomEvent;
+    'comment-save': CustomEvent;
+    'root-id-changed': CustomEvent;
+  }
+}
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
deleted file mode 100644
index 4e425dc..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
new file mode 100644
index 0000000..9921dd6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -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';
+
+export const htmlTemplate = html`
+  <gr-diff
+    id="diff"
+    change-num="[[changeNum]]"
+    no-auto-render="[[noAutoRender]]"
+    patch-range="[[patchRange]]"
+    path="[[path]]"
+    prefs="[[prefs]]"
+    display-line="[[displayLine]]"
+    is-image-diff="[[isImageDiff]]"
+    hidden$="[[hidden]]"
+    no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
+    line-wrapping="[[lineWrapping]]"
+    view-mode="[[viewMode]]"
+    line-of-interest="[[lineOfInterest]]"
+    logged-in="[[_loggedIn]]"
+    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)]]"
+    use-new-context-controls="[[_useNewContextControls()]]"
+  >
+  </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>
+`;
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
deleted file mode 100644
index 0cb2b5e..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ /dev/null
@@ -1,1644 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-diff-host></gr-diff-host>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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.dispatchEvent(
-          new CustomEvent('comment-update', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      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.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('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('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;
-        });
-    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);
-    }
-
-    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('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;
-          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');
-
-          assert.isOk(leftImage);
-          assert.isNotOk(rightImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      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';
-
-        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);
-      });
-    });
-
-    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';
-    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']},
-        ],
-      };
-      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-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]);
-  });
-
-  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,
-          },
-          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);
-  });
-
-  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 = 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-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
new file mode 100644
index 0000000..37b3a50
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -0,0 +1,1693 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.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 {sortComments} from '../../../utils/comment-util.js';
+import {Side} from '../../../constants/constants.js';
+import {createChange} from '../../../test/test-data-generators.js';
+
+const basicFixture = fixtureFromElement('gr-diff-host');
+
+suite('gr-diff-host tests', () => {
+  let element;
+
+  let getLoggedIn;
+
+  setup(() => {
+    getLoggedIn = false;
+    stub('gr-rest-api-interface', {
+      async getLoggedIn() { return getLoggedIn; },
+    });
+    element = basicFixture.instantiate();
+    element.changeNum = 123;
+    element.path = 'some/path';
+    sinon.stub(element.reporting, 'time');
+    sinon.stub(element.reporting, 'timeEnd');
+  });
+
+  suite('plugin layers', () => {
+    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
+    setup(() => {
+      stub('gr-js-api-interface', {
+        getDiffLayers() { return pluginLayers; },
+      });
+      element = basicFixture.instantiate();
+      element.changeNum = 123;
+      element.path = 'some/path';
+    });
+    test('plugin layers requested', () => {
+      element.patchRange = {};
+      element.change = createChange();
+      element.reload();
+      assert(element.$.jsAPI.getDiffLayers.called);
+    });
+  });
+
+  suite('handle comment-update', () => {
+    setup(() => {
+      sinon.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.dispatchEvent(
+          new CustomEvent('comment-update', {
+            detail: {comment},
+            composed: true, bubbles: true,
+          }));
+      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 = sinon.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('saving a draft', () => {
+      const draftID = 'tempID';
+      const id = 'savedID';
+      const comment = {
+        __draft: true,
+        __draftID: draftID,
+        side: 'PARENT',
+        __commentSide: 'left',
+      };
+      const diffCommentsModifiedStub = sinon.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('remove comment', () => {
+    sinon.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'},
+      ],
+    };
+
+    // 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 = element._createThreads([
+      {
+        id: 4711,
+        __commentSide: 'left',
+        updated: '2015-12-20 15:01:20.396000000',
+      },
+      {
+        id: 42,
+        __commentSide: 'left',
+        updated: '2017-12-20 15:01:20.396000000',
+      },
+    ]);
+    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].comments[0].id, 4711);
+    assert.equal(threadEls[1].comments[0].id, 42);
+    for (const threadEl of threadEls) {
+      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].comments[0].id, 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', async () => {
+      sinon.stub(element.reporting, 'diffViewContentDisplayed');
+      let notifySyntaxProcessed;
+      sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+          resolve => {
+            notifySyntaxProcessed = resolve;
+          }));
+      sinon.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      element.change = createChange();
+      element.$.restAPI.getDiffPreferences().then(prefs => {
+        element.prefs = prefs;
+        return element.reload(true);
+      });
+      // Multiple cascading microtasks are scheduled.
+      await flush();
+      notifySyntaxProcessed();
+      // Multiple cascading microtasks are scheduled.
+      await flush();
+      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+          'Diff Total Render'));
+      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+          'Diff Syntax Render'));
+      assert.isTrue(element.reporting.diffViewContentDisplayed.called);
+    });
+
+    test('ends total timer w/ no syntax layer processing', async () => {
+      sinon.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      element.change = createChange();
+      element.reload();
+      // Multiple cascading microtasks are scheduled.
+      await flush();
+      // Reporting can be called with other parameters (ex. PluginsLoaded),
+      // but only 'Diff Total Render' is important in this test.
+      assert.equal(
+          element.reporting.timeEnd.getCalls()
+              .filter(call => call.calledWithExactly('Diff Total Render'))
+              .length,
+          1);
+    });
+
+    test('completes reload promise after syntax layer processing', async () => {
+      let notifySyntaxProcessed;
+      sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+          resolve => {
+            notifySyntaxProcessed = resolve;
+          }));
+      sinon.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      element.change = createChange();
+      let reloadComplete = false;
+      element.$.restAPI.getDiffPreferences()
+          .then(prefs => {
+            element.prefs = prefs;
+            return element.reload();
+          })
+          .then(() => {
+            reloadComplete = true;
+          });
+      // Multiple cascading microtasks are scheduled.
+      await flush();
+      assert.isFalse(reloadComplete);
+      notifySyntaxProcessed();
+      // Assert after the notification task is processed.
+      await flush();
+      assert.isTrue(reloadComplete);
+    });
+  });
+
+  test('reload() cancels before network resolves', () => {
+    const cancelStub = sinon.stub(element.$.diff, 'cancel');
+
+    // Stub the network calls into requests that never resolve.
+    sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
+    element.patchRange = {};
+    element.change = createChange();
+
+    // Needs to be set to something first for it to cancel.
+    element.diff = {
+      content: [{
+        a: ['foo'],
+      }],
+    };
+
+    element.reload();
+    assert.isTrue(cancelStub.called);
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      getLoggedIn = false;
+      element = basicFixture.instantiate();
+      element.changeNum = 123;
+      element.change = createChange();
+      element.path = 'some/path';
+    });
+
+    test('reload() loads files weblinks', () => {
+      const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
+          .returns({name: 'stubb', url: '#s'});
+      sinon.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('prefetch getDiff', done => {
+      const diffRestApiStub = sinon.stub(element.$.restAPI, 'getDiff')
+          .returns(Promise.resolve({content: []}));
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+      element.prefetchDiff();
+      element._getDiff().then(() =>{
+        assert.isTrue(diffRestApiStub.calledOnce);
+        done();
+      });
+    });
+
+    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 = sinon.stub(element, '_handleGetDiffError');
+      const error = new Response(null, {ok: false, status: 500});
+      sinon.stub(element.$.restAPI, 'getDiff').callsFake(
+          (changeNum, basePatchNum, patchNum, path, whitespace, 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',
+        };
+        sinon.stub(element.$.restAPI,
+            'getB64FileContents')
+            .callsFake(
+                (changeId, patchNum, path, opt_parentIndex) => Promise.resolve(
+                    opt_parentIndex === 1 ? mockFile1 : mockFile2)
+            );
+
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        element.change = createChange();
+        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,
+        };
+        sinon.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,
+        };
+        sinon.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,
+        };
+        sinon.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,
+        };
+        sinon.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('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';
+
+        sinon.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 = sinon.stub(element.$.diff, 'cancel');
+    element.patchRange = {};
+    element.cancel();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates getCursorStops()', () => {
+    const returnValue = [document.createElement('b')];
+    const stub = sinon.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 = sinon.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 = sinon.stub(element.$.diff, 'toggleLeftDiff');
+    element.toggleLeftDiff();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  suite('blame', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.changeNum = 123;
+      element.path = 'some/path';
+    });
+
+    test('clearBlame', () => {
+      element._blame = [];
+      const setBlameSpy = sinon.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 = sinon.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('loadBlame empty', () => {
+      const mockBlame = [];
+      const showAlertStub = sinon.stub();
+      element.addEventListener('show-alert', showAlertStub);
+      sinon.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';
+    element.$.diff.appendChild(threadEl);
+    assert.deepEqual(element.getThreadEls(), [threadEl]);
+  });
+
+  test('delegates addDraftAtLine(el)', () => {
+    const param0 = document.createElement('b');
+    const stub = sinon.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 = sinon.stub(element.$.diff, 'clearDiffContent');
+    element.clearDiffContent();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates expandAllContext()', () => {
+    const stub = sinon.stub(element.$.diff, 'expandAllContext');
+    element.expandAllContext();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('passes in changeNum', () => {
+    element.changeNum = 12345;
+    assert.equal(element.$.diff.changeNum, 12345);
+  });
+
+  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', () => {
+    element.changeNum = 12345;
+    assert.equal(element.$.diff.changeNum, 12345);
+  });
+
+  test('passes in displayLine', () => {
+    const value = true;
+    element.displayLine = value;
+    assert.equal(element.$.diff.displayLine, 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 = basicFixture.instantiate();
+      element.changeNum = 123;
+      element.path = 'file.txt';
+      element.patchRange = {basePatchNum: 1};
+      reportStub = sinon.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']},
+        ],
+      };
+      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-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 = sortComments(comments);
+    assert.equal(sortedComments[0], comments[1]);
+    assert.equal(sortedComments[1], comments[2]);
+    assert.equal(sortedComments[2], comments[0]);
+  });
+
+  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].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].lineNum, 1);
+
+    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].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 = [
+      {
+        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,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
+        },
+        lineNum: 1,
+        isOnParent: false,
+      },
+    ];
+
+    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',
+            __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:01:20.396000000',
+            __commentSide: 'left',
+            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 = 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('thread should use old file path if first created' +
+   'on patch set (left) before renaming', () => {
+    const commentSide = 'left';
+    element.file = {basePath: 'file_renamed.txt', path: element.path};
+
+    assert.isOk(element._getOrCreateThread('2', 3,
+        commentSide, undefined, /* isOnParent= */ false));
+
+    const threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 1);
+    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].path, element.file.basePath);
+  });
+
+  test('thread should use new file path if first created' +
+   'on patch set (right) after renaming', () => {
+    const commentSide = 'right';
+    element.file = {basePath: 'file_renamed.txt', path: element.path};
+
+    assert.isOk(element._getOrCreateThread('2', 3,
+        commentSide, undefined, /* isOnParent= */ false));
+
+    const threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 1);
+    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].path, element.file.path);
+  });
+
+  test('thread should use new file path if first created' +
+   'on patch set (left) but is base', () => {
+    const commentSide = 'left';
+    element.file = {basePath: 'file_renamed.txt', path: element.path};
+
+    assert.isOk(element._getOrCreateThread('2', 3,
+        commentSide, undefined, /* isOnParent= */ true));
+
+    const threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 1);
+    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].path, element.file.path);
+  });
+
+  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,
+        Side.LEFT), []);
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+        Side.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,
+        Side.LEFT), [l3]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        Side.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,
+        Side.BOTH), [l, r]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        Side.LEFT), [l]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        Side.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;
+      element.changeNum = 123;
+      element.change = createChange();
+      element.path = 'some/path';
+    });
+
+    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', async () => {
+      sinon.stub(element.$.syntaxLayer, 'process')
+          .returns(Promise.resolve());
+      sinon.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.reload();
+      await flush();
+      element.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true}));
+      assert.isTrue(element.$.syntaxLayer.process.called);
+    });
+  });
+
+  suite('syntax layer with syntax_highlighting off', () => {
+    setup(() => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      element.patchRange = {};
+      element.change = createChange();
+      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', {
+        getCoverageAnnotationApis() {
+          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 = basicFixture.instantiate();
+      element.changeNum = 123;
+      element.change = createChange();
+      element.path = 'some/path';
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      element.patchRange = {};
+      element.prefs = prefs;
+    });
+
+    test('getCoverageAnnotationApis should be called', done => {
+      element.reload();
+      flush(() => {
+        assert.isTrue(element.$.jsAPI.getCoverageAnnotationApis.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;
+        sinon.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;
+        sinon.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;
+        sinon.stub(element, '_lastChunkForSide').callsFake((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;
+        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
+          if (!leftSide) { return null; }
+          return {a: ['foo']};
+        });
+        assert.isNull(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index acd9457..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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,
-      },
-
-      /**
-       * If set to true, the user's preference will be updated every time a
-       * button is tapped. Don't set to true if there is no user.
-       */
-      saveOnChange: {
-        type: Boolean,
-        value: false,
-      },
-
-      /** @type {?} */
-      _VIEW_MODES: {
-        type: Object,
-        readOnly: true,
-        value: {
-          SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-          UNIFIED: 'UNIFIED_DIFF',
-        },
-      },
-    };
-  }
-
-  /**
-   * Set the mode. If save on change is enabled also update the preference.
-   */
-  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' : '';
-  }
-
-  _handleSideBySideTap() {
-    this.setMode(this._VIEW_MODES.SIDE_BY_SIDE);
-  }
-
-  _handleUnifiedTap() {
-    this.setMode(this._VIEW_MODES.UNIFIED);
-  }
-}
-
-customElements.define(GrDiffModeSelector.is, GrDiffModeSelector);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
new file mode 100644
index 0000000..e0333cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -0,0 +1,114 @@
+/**
+ * @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.
+ */
+import '@polymer/iron-icon/iron-icon';
+import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {DiffViewMode} from '../../../constants/constants';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-mode-selector_html';
+import {customElement, property} from '@polymer/decorators';
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import {FixIronA11yAnnouncer} from '../../../types/types';
+
+export interface GrDiffModeSelector {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-diff-mode-selector')
+export class GrDiffModeSelector extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, notify: true})
+  mode?: DiffViewMode;
+
+  /**
+   * If set to true, the user's preference will be updated every time a
+   * button is tapped. Don't set to true if there is no user.
+   */
+  @property({type: Boolean})
+  saveOnChange = false;
+
+  attached() {
+    ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
+  }
+
+  /**
+   * Set the mode. If save on change is enabled also update the preference.
+   */
+  setMode(newMode: DiffViewMode) {
+    if (this.saveOnChange && this.mode && this.mode !== newMode) {
+      this.$.restAPI.savePreferences({diff_view: newMode});
+    }
+    this.mode = newMode;
+    let annoucement;
+    if (this.isUnifiedSelected(newMode)) {
+      annoucement = 'Changed diff view to unified';
+    } else if (this.isSideBySideSelected(newMode)) {
+      annoucement = 'Changed diff view to side by side';
+    }
+    if (annoucement) {
+      this.fire(
+        'iron-announce',
+        {
+          text: annoucement,
+        },
+        {bubbles: true}
+      );
+    }
+  }
+
+  _computeSideBySideSelected(mode: DiffViewMode) {
+    return mode === DiffViewMode.SIDE_BY_SIDE ? 'selected' : '';
+  }
+
+  _computeUnifiedSelected(mode: DiffViewMode) {
+    return mode === DiffViewMode.UNIFIED ? 'selected' : '';
+  }
+
+  isSideBySideSelected(mode: DiffViewMode) {
+    return mode === DiffViewMode.SIDE_BY_SIDE;
+  }
+
+  isUnifiedSelected(mode: DiffViewMode) {
+    return mode === DiffViewMode.UNIFIED;
+  }
+
+  _handleSideBySideTap() {
+    this.setMode(DiffViewMode.SIDE_BY_SIDE);
+  }
+
+  _handleUnifiedTap() {
+    this.setMode(DiffViewMode.UNIFIED);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-mode-selector': 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
deleted file mode 100644
index b5393ea..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
new file mode 100644
index 0000000..a5bf269
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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$="[[_computeSideBySideSelected(mode)]]"
+    title="Side-by-side diff"
+    aria-pressed="[[isSideBySideSelected(mode)]]"
+    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$="[[_computeUnifiedSelected(mode)]]"
+    aria-pressed="[[isUnifiedSelected(mode)]]"
+    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
deleted file mode 100644
index 309f4ac..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
+++ /dev/null
@@ -1,84 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-mode-selector></gr-diff-mode-selector>
-  </template>
-</test-fixture>
-
-<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);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
new file mode 100644
index 0000000..07a1d16
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
@@ -0,0 +1,67 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-mode-selector.js';
+import {DiffViewMode} from '../../../constants/constants.js';
+
+const basicFixture = fixtureFromElement('gr-diff-mode-selector');
+
+suite('gr-diff-mode-selector tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeSelectedClass', () => {
+    assert.equal(element._computeSideBySideSelected(DiffViewMode.SIDE_BY_SIDE),
+        'selected');
+    assert.equal(element._computeSideBySideSelected(DiffViewMode.UNIFIED),
+        '');
+    assert.equal(element._computeUnifiedSelected(DiffViewMode.UNIFIED),
+        'selected');
+    assert.equal(element._computeUnifiedSelected(DiffViewMode.SIDE_BY_SIDE),
+        '');
+  });
+
+  test('setMode', () => {
+    const saveStub = sinon.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);
+  });
+});
+
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
deleted file mode 100644
index 8f48507..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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,
-
-      /**
-       * _editableDiffPrefs is a clone of diffPrefs.
-       * All changes in the dialog are applied to this object
-       * immediately, when a value in an editor is changed.
-       * The "Save" button replaces the "diffPrefs" object with
-       * the value of _editableDiffPrefs.
-       *
-       * @type {?}
-       */
-      _editableDiffPrefs: Object,
-
-      _diffPrefsChanged: Boolean,
-    };
-  }
-
-  getFocusStops() {
-    return {
-      start: this.$.diffPreferences.$.contextSelect,
-      end: this.$.saveButton,
-    };
-  }
-
-  resetFocus() {
-    this.$.diffPreferences.$.contextSelect.focus();
-  }
-
-  _computeHeaderClass(changed) {
-    return changed ? 'edited' : '';
-  }
-
-  _handleCancelDiff(e) {
-    e.stopPropagation();
-    this.$.diffPrefsOverlay.close();
-  }
-
-  open() {
-    // 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();
-    });
-  }
-}
-
-customElements.define(GrDiffPreferencesDialog.is, GrDiffPreferencesDialog);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
new file mode 100644
index 0000000..c66af58
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -0,0 +1,113 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-diff-preferences/gr-diff-preferences';
+import '../../shared/gr-overlay/gr-overlay';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-preferences-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {DiffPreferencesInfo} from '../../../types/common';
+
+export interface GrDiffPreferencesDialog {
+  $: {
+    diffPreferences: GrDiffPreferences;
+    saveButton: GrButton;
+    cancelButton: GrButton;
+    diffPrefsOverlay: GrOverlay;
+  };
+}
+@customElement('gr-diff-preferences-dialog')
+export class GrDiffPreferencesDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  _editableDiffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Boolean, observer: '_onDiffPrefsChanged'})
+  _diffPrefsChanged?: boolean;
+
+  getFocusStops() {
+    return {
+      start: this.$.diffPreferences.$.contextSelect,
+      end: this.$.saveButton.disabled ? this.$.cancelButton : this.$.saveButton,
+    };
+  }
+
+  resetFocus() {
+    this.$.diffPreferences.$.contextSelect.focus();
+  }
+
+  _computeHeaderClass(changed: boolean) {
+    return changed ? 'edited' : '';
+  }
+
+  _handleCancelDiff(e: MouseEvent) {
+    e.stopPropagation();
+    this.$.diffPrefsOverlay.close();
+  }
+
+  _onDiffPrefsChanged() {
+    this.$.diffPrefsOverlay.setFocusStops(this.getFocusStops());
+  }
+
+  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)
+    ) as DiffPreferencesInfo;
+    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();
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-preferences-dialog': 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
deleted file mode 100644
index d65ba1f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
new file mode 100644
index 0000000..787fe30
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
@@ -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';
+
+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 role="dialog" aria-labelledby="diffPreferencesTitle">
+      <h1
+        class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]"
+        id="diffPreferencesTitle"
+      >
+        Diff Preferences
+      </h1>
+      <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>
+    </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
deleted file mode 100644
index d3050af..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html
+++ /dev/null
@@ -1,69 +0,0 @@
-<!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-diff-preferences-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 src="/components/web-component-tester/data/a11ySuite.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-preferences-dialog></gr-diff-preferences-dialog>
-  </template>
-</test-fixture>
-
-<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';
-
-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-preferences-dialog/gr-diff-preferences-dialog_test.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.js
new file mode 100644
index 0000000..07cca9a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.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 '../../../test/common-test-setup-karma.js';
+
+const basicFixture = fixtureFromElement('gr-diff-preferences-dialog');
+
+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;
+
+    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);
+  });
+});
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
deleted file mode 100644
index 62ddfee..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ /dev/null
@@ -1,671 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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 {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
-import {util} from '../../../scripts/util.js';
-
-const WHOLE_FILE = -1;
-
-const DiffSide = {
-  LEFT: 'left',
-  RIGHT: 'right',
-};
-
-const DiffHighlights = {
-  ADDED: 'edit_b',
-  REMOVED: 'edit_a',
-};
-
-/**
- * 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;
-
-/**
- * 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.
-       */
-      context: Number,
-
-      /**
-       * The array of groups output by the processor.
-       */
-      groups: {
-        type: Array,
-        notify: true,
-      },
-
-      /**
-       * Locations that should not be collapsed, including the locations of
-       * comments.
-       */
-      keyLocations: {
-        type: Object,
-        value() { return {left: {}, right: {}}; },
-      },
-
-      /**
-       * The maximum number of lines to process synchronously.
-       */
-      _asyncThreshold: {
-        type: Number,
-        value: 64,
-      },
-
-      /** @type {?number} */
-      _nextStepHandle: Number,
-      /**
-       * The promise last returned from `process()` while the asynchronous
-       * processing is running - `null` otherwise. Provides a `cancel()`
-       * method that rejects it with `{isCancelled: true}`.
-       *
-       * @type {?Object}
-       */
-      _processPromise: {
-        type: Object,
-        value: null,
-      },
-      _isScrolling: Boolean,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.listen(window, 'scroll', '_handleWindowScroll');
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.cancel();
-    this.unlisten(window, 'scroll', '_handleWindowScroll');
-  }
-
-  _handleWindowScroll() {
-    this._isScrolling = true;
-    this.debounce('resetIsScrolling', () => {
-      this._isScrolling = false;
-    }, 50);
-  }
-
-  /**
-   * Asynchronously process the diff chunks into groups. As it processes, it
-   * 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());
-
-    // 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,
-          };
-
-          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;
-            }
-
-            // 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);
-            }
-          };
-
-          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();
-    }
-  }
-
-  /**
-   * 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);
-    }
-
-    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;
-    });
-  }
-
-  /**
-   * @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;
-  }
-
-  _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} 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 {
-        newChunks.push(chunk);
-      }
-    }
-    return newChunks;
-  }
-
-  /**
-   * 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;
-      }
-
-      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})));
-      }
-    }
-
-    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;
-        }
-        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.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
new file mode 100644
index 0000000..ab7ab8a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
@@ -0,0 +1,730 @@
+/**
+ * @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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {
+  GrDiffLine,
+  GrDiffLineType,
+  FILE,
+  Highlights,
+} from '../gr-diff/gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from '../gr-diff/gr-diff-group';
+import {CancelablePromise, util} from '../../../scripts/util';
+import {customElement, property} from '@polymer/decorators';
+import {DiffContent} from '../../../types/common';
+import {Side} from '../../../constants/constants';
+
+const WHOLE_FILE = -1;
+
+interface State {
+  lineNums: {
+    left: number;
+    right: number;
+  };
+  chunkIndex: number;
+}
+
+interface ChunkEnd {
+  offset: number;
+  keyLocation: boolean;
+}
+
+export interface KeyLocations {
+  left: {[key: string]: boolean};
+  right: {[key: string]: boolean};
+}
+
+/**
+ * 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;
+
+/**
+ * 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.
+ */
+@customElement('gr-diff-processor')
+export class GrDiffProcessor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  @property({type: Number})
+  context = 3;
+
+  @property({type: Array, notify: true})
+  groups: GrDiffGroup[] = [];
+
+  @property({type: Object})
+  keyLocations: KeyLocations = {left: {}, right: {}};
+
+  @property({type: Number})
+  _asyncThreshold = 64;
+
+  @property({type: Number})
+  _nextStepHandle: number | null = null;
+
+  @property({type: Object})
+  _processPromise: CancelablePromise<void> | null = null;
+
+  @property({type: Boolean})
+  _isScrolling?: boolean;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(window, 'scroll', '_handleWindowScroll');
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancel();
+    this.unlisten(window, 'scroll', '_handleWindowScroll');
+  }
+
+  _handleWindowScroll() {
+    this._isScrolling = true;
+    this.debounce(
+      'resetIsScrolling',
+      () => {
+        this._isScrolling = false;
+      },
+      50
+    );
+  }
+
+  /**
+   * Asynchronously process the diff chunks into groups. As it processes, it
+   * will splice groups into the `groups` property of the component.
+   *
+   * @return A promise that resolves with an
+   * array of GrDiffGroups when the diff is completely processed.
+   */
+  process(chunks: DiffContent[], isBinary: boolean) {
+    // Cancel any still running process() calls, because they append to the
+    // same groups field.
+    this.cancel();
+
+    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();
+    }
+
+    this._processPromise = util.makeCancelable(
+      new Promise(resolve => {
+        const state = {
+          lineNums: {left: 0, right: 0},
+          chunkIndex: 0,
+        };
+
+        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;
+          }
+
+          // 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);
+          }
+        };
+
+        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();
+    }
+  }
+
+  /**
+   * Process the next uncollapsible chunk, or the next collapsible chunks.
+   */
+  _processNext(state: State, chunks: DiffContent[]) {
+    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: DiffContent) {
+    return chunk.ab || chunk.a || [];
+  }
+
+  _linesRight(chunk: DiffContent) {
+    return chunk.ab || chunk.b || [];
+  }
+
+  _firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
+    let chunkIndex = offset;
+    while (
+      chunkIndex < chunks.length &&
+      this._isCollapsibleChunk(chunks[chunkIndex])
+    ) {
+      chunkIndex++;
+    }
+    return chunkIndex;
+  }
+
+  _isCollapsibleChunk(chunk: DiffContent) {
+    return (chunk.ab || chunk.common || chunk.skip) && !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.
+   */
+  _processCollapsibleChunks(
+    state: State,
+    chunks: DiffContent[],
+    firstUncollapsibleChunkIndex: number
+  ) {
+    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
+    );
+
+    const hasSkippedGroup = !!groups.find(g => g.skip);
+    if (this.context !== WHOLE_FILE || hasSkippedGroup) {
+      const contextNumLines = this.context > 0 ? this.context : 0;
+      const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
+      const hiddenEnd =
+        lineCount -
+        (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
+      groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
+    }
+
+    return {
+      lineDelta: {
+        left: lineCount,
+        right: lineCount,
+      },
+      groups,
+      newChunkIndex: firstUncollapsibleChunkIndex,
+    };
+  }
+
+  _commonChunkLength(chunk: DiffContent) {
+    if (chunk.skip) {
+      return chunk.skip;
+    }
+    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;
+  }
+
+  _chunksToGroups(
+    chunks: DiffContent[],
+    offsetLeft: number,
+    offsetRight: number
+  ): GrDiffGroup[] {
+    return chunks.map(chunk => {
+      const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
+      const chunkLength = this._commonChunkLength(chunk);
+      offsetLeft += chunkLength;
+      offsetRight += chunkLength;
+      return group;
+    });
+  }
+
+  _chunkToGroup(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ): GrDiffGroup {
+    const type =
+      chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.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.dueToMove = !!chunk.due_to_move;
+    group.skip = chunk.skip;
+    group.ignoredWhitespaceOnly = !!chunk.common;
+    if (chunk.skip) {
+      group.lineRange = {
+        left: {start: offsetLeft, end: offsetLeft + chunk.skip - 1},
+        right: {start: offsetRight, end: offsetRight + chunk.skip - 1},
+      };
+    }
+    return group;
+  }
+
+  _linesFromChunk(chunk: DiffContent, offsetLeft: number, offsetRight: number) {
+    if (chunk.ab) {
+      return chunk.ab.map((row, i) =>
+        this._lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
+      );
+    }
+    let lines: GrDiffLine[] = [];
+    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(
+          GrDiffLineType.REMOVE,
+          chunk.a,
+          offsetLeft,
+          chunk.edit_a
+        )
+      );
+    }
+    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(
+          GrDiffLineType.ADD,
+          chunk.b,
+          offsetRight,
+          chunk.edit_b
+        )
+      );
+    }
+    return lines;
+  }
+
+  _linesFromRows(
+    lineType: GrDiffLineType,
+    rows: string[],
+    offset: number,
+    intralineInfos?: number[][]
+  ): GrDiffLine[] {
+    const grDiffHighlights = intralineInfos
+      ? this._convertIntralineInfos(rows, intralineInfos)
+      : undefined;
+    return rows.map((row, i) =>
+      this._lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
+    );
+  }
+
+  _lineFromRow(
+    type: GrDiffLineType,
+    offsetLeft: number,
+    offsetRight: number,
+    row: string,
+    i: number,
+    highlights?: Highlights[]
+  ): GrDiffLine {
+    const line = new GrDiffLine(type);
+    line.text = row;
+    if (type !== GrDiffLineType.ADD) line.beforeNumber = offsetLeft + i;
+    if (type !== GrDiffLineType.REMOVE) line.afterNumber = offsetRight + i;
+    if (highlights) {
+      line.hasIntralineInfo = true;
+      line.highlights = highlights.filter(hl => hl.contentIndex === i);
+    } else {
+      line.hasIntralineInfo = false;
+    }
+    return line;
+  }
+
+  _makeFileComments() {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.beforeNumber = FILE;
+    line.afterNumber = FILE;
+    return new GrDiffGroup(GrDiffGroupType.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 chunks Chunks as returned from the server
+   * @return Finer grained chunks.
+   */
+  _splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
+    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 {
+        newChunks.push(chunk);
+      }
+    }
+    return newChunks;
+  }
+
+  /**
+   * 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 chunks DiffContents as returned from server.
+   * @return Finer grained DiffContents.
+   */
+  _splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
+    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.skip && !chunk.common) {
+        if (chunk.a) {
+          leftLineNum += chunk.a.length;
+        }
+        if (chunk.b) {
+          rightLineNum += chunk.b.length;
+        }
+        result.push(chunk);
+        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.skip) {
+        result.push({
+          ...chunk,
+          skip: chunk.skip,
+          keyLocation: false,
+        });
+      } else if (chunk.ab) {
+        result.push(
+          ...this._splitAtChunkEnds(chunk.ab, chunkEnds).map(
+            ({lines, keyLocation}) => {
+              return {
+                ...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) => {
+            return {
+              ...chunk,
+              a: lines,
+              b: bChunks[i].lines,
+              keyLocation,
+            };
+          })
+        );
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * @return Offsets of the new chunk ends, including whether it's a key
+   * location.
+   */
+  _findChunkEndsAtKeyLocations(
+    numLines: number,
+    leftOffset: number,
+    rightOffset: number
+  ): ChunkEnd[] {
+    const result = [];
+    let lastChunkEnd = 0;
+    for (let i = 0; i < numLines; i++) {
+      // If this line should not be collapsed.
+      if (
+        this.keyLocations[Side.LEFT][leftOffset + i] ||
+        this.keyLocations[Side.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: string[], chunkEnds: ChunkEnd[]) {
+    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.
+   */
+  _convertIntralineInfos(
+    rows: string[],
+    intralineInfos: number[][]
+  ): Highlights[] {
+    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: Highlights = {
+        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.
+   */
+  _breakdownChunk(chunk: DiffContent): DiffContent[] {
+    let key: 'a' | 'b' | 'ab' | null = 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: DiffContent = {};
+      subChunk[key!] = subChunkLines;
+      if (chunk.due_to_rebase) {
+        subChunk.due_to_rebase = true;
+      }
+      if (chunk.due_to_move) {
+        subChunk.due_to_move = 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.
+   */
+  _breakdown<T>(array: T[], size: number): T[][] {
+    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]);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-processor': 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
deleted file mode 100644
index 50bfe107..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ /dev/null
@@ -1,936 +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">
-<meta charset="utf-8">
-<title>gr-diff-processor test</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-diff-processor></gr-diff-processor>
-  </template>
-</test-fixture>
-
-<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';
-
-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(() => {
-      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), [
-          '<!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 = [
-          {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},
-      };
-
-      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.',
-          ],
-        },
-      ];
-      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,
-        },
-      ]);
-    });
-
-    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(' ');
-      });
-
-      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(() => {
-          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) => 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-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
new file mode 100644
index 0000000..ce7a3c4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -0,0 +1,1077 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import 'lodash/lodash.js';
+import './gr-diff-processor.js';
+import {GrDiffLineType, FILE} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
+
+const basicFixture = fixtureFromElement('gr-diff-processor');
+
+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;
+
+  setup(() => {
+
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+
+      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, GrDiffGroupType.BOTH);
+        assert.equal(group.lines.length, 1);
+        assert.equal(group.lines[0].text, '');
+        assert.equal(group.lines[0].beforeNumber, FILE);
+        assert.equal(group.lines[0].afterNumber, FILE);
+
+        group = groups[1];
+        assert.equal(group.type, GrDiffGroupType.BOTH);
+        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, GrDiffGroupType.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, GrDiffGroupType.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, GrDiffGroupType.BOTH);
+        assert.equal(groups[0].lines.length, 1);
+        assert.equal(groups[0].lines[0].text, '');
+        assert.equal(groups[0].lines[0].beforeNumber, FILE);
+        assert.equal(groups[0].lines[0].afterNumber, FILE);
+      });
+    });
+
+    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, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[1].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[1].contextGroups[0].lines.length, 90);
+          for (const l of groups[1].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+
+          assert.equal(groups[2].type, GrDiffGroupType.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 with skip chunks', async () => {
+        element.context = 10;
+        const content = [
+          {ab: new Array(20)
+              .fill('all work and no play make jack a dull boy')},
+          {skip: 43900},
+          {ab: new Array(30)
+              .fill('some other content')},
+          {a: ['some other content']},
+        ];
+
+        await element.process(content);
+
+        const groups = element.groups;
+
+        // group[0] is the file group
+
+        const commonGroup = groups[1];
+
+        // Hidden context before
+        assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+        assert.equal(commonGroup.contextGroups[0].lines.length, 20);
+        for (const l of commonGroup.contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
+
+        // Skipped group
+        const skipGroup = commonGroup.contextGroups[1];
+        assert.equal(skipGroup.skip, 43900);
+        const expectedRange = {
+          left: {start: 21, end: 43920},
+          right: {start: 21, end: 43920},
+        };
+        assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+        // Hidden context after
+        assert.equal(commonGroup.contextGroups[2].lines.length, 20);
+        for (const l of commonGroup.contextGroups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+
+        // Displayed lines
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 10);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+      });
+
+      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, GrDiffGroupType.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, GrDiffGroupType.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, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].contextGroups[0].lines.length, 90);
+          for (const l of groups[3].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, GrDiffGroupType.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, GrDiffGroupType.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, GrDiffGroupType.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, GrDiffGroupType.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, GrDiffGroupType.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, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.equal(groups[6].contextGroups.length, 2);
+
+          assert.equal(groups[6].contextGroups[0].lines.length, 4);
+          assert.equal(groups[6].contextGroups[0].removes.length, 2);
+          assert.equal(groups[6].contextGroups[0].adds.length, 2);
+          for (const l of groups[6].contextGroups[0].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[6].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].contextGroups[1].type,
+              GrDiffGroupType.BOTH);
+          assert.equal(groups[6].contextGroups[1].lines.length, 3);
+          for (const l of groups[6].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, GrDiffGroupType.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, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].contextGroups[0].lines.length, 80);
+          for (const l of groups[3].contextGroups[0].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[4].type, GrDiffGroupType.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, GrDiffGroupType.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('in the middle with skip chunks', async () => {
+      element.context = 10;
+      const content = [
+        {a: ['all work and no play make andybons a dull boy']},
+        {ab: new Array(20)
+            .fill('all work and no play make jill a dull girl')},
+        {skip: 60},
+        {ab: new Array(20)
+            .fill('all work and no play make jill a dull girl')},
+        {a: ['all work and no play make andybons a dull boy']},
+      ];
+
+      await element.process(content);
+
+      const groups = element.groups;
+
+      // group[0] is the file group
+      // group[1] is the chunk with a
+      // group[2] is the displayed part of ab before
+
+      const commonGroup = groups[3];
+
+      // Hidden context before
+      assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+      assert.equal(commonGroup.contextGroups[0].lines.length, 10);
+      for (const l of commonGroup.contextGroups[0].lines) {
+        assert.equal(
+            l.text, 'all work and no play make jill a dull girl');
+      }
+
+      // Skipped group
+      const skipGroup = commonGroup.contextGroups[1];
+      assert.equal(skipGroup.skip, 60);
+      const expectedRange = {
+        left: {start: 22, end: 81},
+        right: {start: 21, end: 80},
+      };
+      assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+      // Hidden context after
+      assert.equal(commonGroup.contextGroups[2].lines.length, 10);
+      for (const l of commonGroup.contextGroups[2].lines) {
+        assert.equal(
+            l.text, 'all work and no play make jill a dull girl');
+      }
+      // group[4] is the displayed part of the second ab
+    });
+
+    test('break up common diff chunks', () => {
+      element.keyLocations = {
+        left: {1: true},
+        right: {10: true},
+      };
+
+      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.',
+          ],
+        },
+      ];
+      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,
+        },
+      ]);
+    });
+
+    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,
+        },
+      ]);
+      const lines = element._linesFromRows(
+          GrDiffGroupType.BOTH, content, 0, highlights);
+      assert.equal(lines.length, 3);
+      assert.isTrue(lines[0].hasIntralineInfo);
+      assert.equal(lines[0].highlights.length, 1);
+      assert.isTrue(lines[1].hasIntralineInfo);
+      assert.equal(lines[1].highlights.length, 2);
+      assert.isTrue(lines[2].hasIntralineInfo);
+      assert.equal(lines[2].highlights.length, 1);
+
+      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: [
+          '',
+          '',
+        ],
+      };
+      const content = _.times(200, _.constant(contentRow));
+      sinon.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: [
+          '',
+          '',
+        ],
+      };
+      const content = _.times(200, _.constant(contentRow));
+      sinon.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(' ');
+      });
+
+      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, GrDiffGroupType.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('WHOLE_FILE with skip chunks still get collapsed', () => {
+        element.context = WHOLE_FILE;
+        const lineNums = {left: 10, right: 100};
+        const state = {
+          lineNums,
+          chunkIndex: 1,
+        };
+        const skip = 10000;
+        const chunks = [
+          {a: ['foo']},
+          {skip},
+          {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, GrDiffGroupType.CONTEXT_CONTROL);
+
+        // Skip and ab group are hidden in the same context control
+        assert.equal(result.groups[0].contextGroups.length, 2);
+        const [skippedGroup, abGroup] = result.groups[0].contextGroups;
+
+        // Line numbers are set correctly.
+        assert.deepEqual(
+            skippedGroup.lineRange,
+            {
+              left: {start: lineNums.left + 1, end: lineNums.left + skip},
+              right: {start: lineNums.right + 1, end: lineNums.right + skip},
+            });
+
+        assert.deepEqual(
+            abGroup.lineRange,
+            {
+              left: {
+                start: lineNums.left + skip + 1,
+                end: lineNums.left + skip + rows.length,
+              },
+              right: {
+                start: lineNums.right + skip + 1,
+                end: lineNums.right + skip + 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[2].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(result.groups[1].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[1].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(result.groups[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(() => {
+          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);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[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);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[1].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(GrDiffLineType.ADD, rows,
+            startLineNum + 1);
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLineType.ADD);
+        assert.notOk(result[0].hasIntralineInfo);
+        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(GrDiffLineType.REMOVE, rows,
+            startLineNum + 1);
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLineType.REMOVE);
+        assert.notOk(result[0].hasIntralineInfo);
+        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', () => {
+        sinon.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',
+          () => {
+            sinon.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('_breakdownChunk keeps due_to_move for broken down additions',
+          () => {
+            sinon.spy(element, '_breakdown');
+            const chunk = {b: ['blah', 'blah', 'blah'], due_to_move: true};
+            const result = element._breakdownChunk(chunk);
+            for (const subResult of result) {
+              assert.isTrue(subResult.due_to_move);
+            }
+          });
+
+      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 = basicFixture.instantiate();
+    sinon.stub(element, 'cancel');
+    element.detached();
+    assert(element.cancel.called);
+  });
+});
+
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
deleted file mode 100644
index 08967e8..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ /dev/null
@@ -1,376 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * 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',
-};
-
-const getNewCache = () => { return {left: null, right: null}; };
-
-/**
- * @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,
-      _linesCache: {
-        type: Object,
-        value: getNewCache(),
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_diffChanged(diff)',
-    ];
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('copy',
-        e => this._handleCopy(e));
-    addListener(this, 'down',
-        e => this._handleDown(e));
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.classList.add(SelectionClass.RIGHT);
-  }
-
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder =
-          dom(this).querySelector('gr-diff-builder');
-    }
-    return this._cachedDiffBuilder;
-  }
-
-  _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' ?
-        SelectionClass.LEFT :
-        SelectionClass.RIGHT);
-
-      if (commentSelected) {
-        targetClasses.push(SelectionClass.COMMENT);
-      }
-    }
-
-    this._setClasses(targetClasses);
-  }
-
-  /**
-   * 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);
-      }
-    }
-  }
-
-  _getCopyEventTarget(e) {
-    return 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;
-    }
-    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('');
-          }
-        }
-
-        if (el.id === 'output' &&
-            !this._elementDescendedFromClass(el, 'collapsed')) {
-          content.push(this._getTextContentForRange(el, sel, range));
-        }
-      }
-    }
-
-    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.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
new file mode 100644
index 0000000..b75ba8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
@@ -0,0 +1,388 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {addListener} from '@polymer/polymer/lib/utils/gestures';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-selection_html';
+import {
+  normalize,
+  NormalizedRange,
+} from '../gr-diff-highlight/gr-range-normalizer';
+import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {DiffInfo} from '../../../types/common';
+import {Side} from '../../../constants/constants';
+import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+
+/**
+ * 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',
+};
+
+interface LinesCache {
+  left: string[] | null;
+  right: string[] | null;
+}
+
+function getNewCache(): LinesCache {
+  return {left: null, right: null};
+}
+
+@customElement('gr-diff-selection')
+export class GrDiffSelection extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  _cachedDiffBuilder?: GrDiffBuilderElement;
+
+  @property({type: Object})
+  _linesCache: LinesCache = {left: null, right: null};
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('copy', e => this._handleCopy(e));
+    addListener(this, 'down', e => this._handleDown(e));
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.classList.add(SelectionClass.RIGHT);
+  }
+
+  get diffBuilder() {
+    if (!this._cachedDiffBuilder) {
+      this._cachedDiffBuilder = this.querySelector(
+        'gr-diff-builder'
+      ) as GrDiffBuilderElement;
+    }
+    return this._cachedDiffBuilder;
+  }
+
+  @observe('diff')
+  _diffChanged() {
+    this._linesCache = getNewCache();
+  }
+
+  _handleDownOnRangeComment(node: Element) {
+    if (node?.nodeName?.toLowerCase() === 'gr-comment-thread') {
+      this._setClasses([
+        SelectionClass.COMMENT,
+        node.getAttribute('comment-side') === Side.LEFT
+          ? SelectionClass.LEFT
+          : SelectionClass.RIGHT,
+      ]);
+      return true;
+    }
+    return false;
+  }
+
+  _handleDown(e: Event) {
+    const target = e.target;
+    if (!(target instanceof Element)) return;
+    // Handle the down event on comment thread in Polymer 2
+    const handled = this._handleDownOnRangeComment(target);
+    if (handled) return;
+    const lineEl = this.diffBuilder.getLineElByChild(target);
+    const blameSelected = this._elementDescendedFromClass(target, 'blame');
+    if (!lineEl && !blameSelected) {
+      return;
+    }
+
+    const targetClasses = [];
+
+    if (blameSelected) {
+      targetClasses.push(SelectionClass.BLAME);
+    } else if (lineEl) {
+      const commentSelected = this._elementDescendedFromClass(
+        target,
+        'gr-comment'
+      );
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
+
+      targetClasses.push(
+        side === 'left' ? SelectionClass.LEFT : SelectionClass.RIGHT
+      );
+
+      if (commentSelected) {
+        targetClasses.push(SelectionClass.COMMENT);
+      }
+    }
+
+    this._setClasses(targetClasses);
+  }
+
+  /**
+   * Set the provided list of classes on the element, to the exclusion of all
+   * other SelectionClass values.
+   */
+  _setClasses(targetClasses: string[]) {
+    // Remove any selection classes that do not belong.
+    for (const className of Object.values(SelectionClass)) {
+      if (!targetClasses.includes(className)) {
+        this.classList.remove(className);
+      }
+    }
+    // Add new selection classes iff they are not already present.
+    for (const _class of targetClasses) {
+      if (!this.classList.contains(_class)) {
+        this.classList.add(_class);
+      }
+    }
+  }
+
+  _getCopyEventTarget(e: Event) {
+    return (dom(e) as EventApi).rootTarget;
+  }
+
+  /**
+   * Utility function to determine whether an element is a descendant of
+   * another element with the particular className.
+   */
+  _elementDescendedFromClass(element: Element, className: string) {
+    return descendedFromClass(element, className, this.diffBuilder.diffElement);
+  }
+
+  _handleCopy(e: ClipboardEvent) {
+    let commentSelected = false;
+    const target = this._getCopyEventTarget(e);
+    if (!(target instanceof Element)) return;
+    if (target instanceof HTMLTextAreaElement) 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) {
+      e.clipboardData.setData('Text', text);
+      e.preventDefault();
+    }
+  }
+
+  _getSelection() {
+    const diffHosts = 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 side The side that is selected.
+   * @param commentSelected Whether or not a comment is selected.
+   * @return The selected text.
+   */
+  _getSelectedText(side: Side, commentSelected: boolean) {
+    const sel = this._getSelection();
+    if (!sel || sel.rangeCount !== 1) {
+      return ''; // No multi-select support yet.
+    }
+    if (commentSelected) {
+      return this._getCommentLines(sel, side);
+    }
+    const range = normalize(sel.getRangeAt(0));
+    const startLineEl = this.diffBuilder.getLineElByChild(range.startContainer);
+    if (!startLineEl) return;
+    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 instanceof HTMLTableCellElement &&
+      (range.endContainer.classList.contains('left') ||
+        range.endContainer.classList.contains('right'));
+    const startLineDataValue = startLineEl.getAttribute('data-value');
+    if (!startLineDataValue) return;
+    const startLineNum = Number(startLineDataValue);
+    let endLineNum;
+    if (endsAtOtherEmptySide) {
+      endLineNum = startLineNum + 1;
+    } else if (endLineEl) {
+      const endLineDataValue = endLineEl.getAttribute('data-value');
+      if (endLineDataValue) endLineNum = Number(endLineDataValue);
+    }
+
+    return this._getRangeFromDiff(
+      startLineNum,
+      range.startOffset,
+      endLineNum,
+      range.endOffset,
+      side
+    );
+  }
+
+  /**
+   * Query the diff object for the selected lines.
+   */
+  _getRangeFromDiff(
+    startLineNum: number,
+    startOffset: number,
+    endLineNum: number | undefined,
+    endOffset: number,
+    side: 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 side The side that is currently selected.
+   * @return An array of strings indexed by line number.
+   */
+  _getDiffLines(side: Side): string[] {
+    if (this._linesCache[side]) {
+      return this._linesCache[side]!;
+    }
+    if (!this.diff) return [];
+    let lines: string[] = [];
+    for (const chunk of this.diff.content) {
+      if (chunk.ab) {
+        lines = lines.concat(chunk.ab);
+      } else if (side === Side.LEFT && chunk.a) {
+        lines = lines.concat(chunk.a);
+      } else if (side === Side.RIGHT && chunk.b) {
+        lines = lines.concat(chunk.b);
+      }
+    }
+    this._linesCache[side] = lines;
+    return lines;
+  }
+
+  /**
+   * Query the diffElement for comments and check whether they lie inside the
+   * selection range.
+   *
+   * @param sel The selection of the window.
+   * @param side The side that is currently selected.
+   * @return The selected comment text.
+   */
+  _getCommentLines(sel: Selection, side: Side) {
+    const range = 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('');
+          }
+        }
+
+        if (
+          el.id === 'output' &&
+          !this._elementDescendedFromClass(el, 'collapsed')
+        ) {
+          content.push(this._getTextContentForRange(el, sel, range));
+        }
+      }
+    }
+
+    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 domNode The root DOM node.
+   * @param sel The selection.
+   * @param range The normalized selection range.
+   * @return The text within the selection.
+   */
+  _getTextContentForRange(
+    domNode: Node,
+    sel: Selection,
+    range: NormalizedRange
+  ) {
+    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;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-selection': 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
deleted file mode 100644
index 620ef02..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts
new file mode 100644
index 0000000..bd0e034
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 1221b58..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ /dev/null
@@ -1,401 +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">
-<meta charset="utf-8">
-<title>gr-diff-selection</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-diff-selection>
-      <table id="diffTable" class="side-by-side">
-        <tr class="diff-row">
-          <td class="blame" data-line-number="1"></td>
-          <td class="lineNum left" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ba ba</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="2"></td>
-          <td class="lineNum left" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="left">zin</div>
-          </td>
-          <td class="lineNum right" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="right">more more more</div>
-            <div data-side="right">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment on the right</span>
-                </div>
-              </div>
-            </div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="3"></td>
-          <td class="lineNum left" data-value="3">3</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="3">3</td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="4"></td>
-          <td class="lineNum left" data-value="4">4</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <textarea data-side="right">test for textarea copying</textarea>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="4">4</td>
-        </tr>
-        <tr class="not-diff-row">
-          <td class="other">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-      </table>
-    </gr-diff-selection>
-  </template>
-</test-fixture>
-
-<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(),
-      },
-    };
-    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'],
-        },
-        {
-          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.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});
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
new file mode 100644
index 0000000..5c9fe3f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
@@ -0,0 +1,389 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-selection.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len */
+const basicFixture = fixtureFromTemplate(html`
+<gr-diff-selection>
+      <table id="diffTable" class="side-by-side">
+        <tr class="diff-row">
+          <td class="blame" data-line-number="1"></td>
+          <td class="lineNum left" data-value="1">1</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ba ba</div>
+            <div data-side="left">
+              <div class="comment-thread">
+                <div class="gr-formatted-text message">
+                  <span id="output" class="gr-linked-text">This is a comment</span>
+                </div>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="1">1</td>
+          <td class="content">
+            <div class="contentText" data-side="right">some other text</div>
+          </td>
+        </tr>
+        <tr class="diff-row">
+          <td class="blame" data-line-number="2"></td>
+          <td class="lineNum left" data-value="2">2</td>
+          <td class="content">
+            <div class="contentText" data-side="left">zin</div>
+          </td>
+          <td class="lineNum right" data-value="2">2</td>
+          <td class="content">
+            <div class="contentText" data-side="right">more more more</div>
+            <div data-side="right">
+              <div class="comment-thread">
+                <div class="gr-formatted-text message">
+                  <span id="output" class="gr-linked-text">This is a comment on the right</span>
+                </div>
+              </div>
+            </div>
+          </td>
+        </tr>
+        <tr class="diff-row">
+          <td class="blame" data-line-number="3"></td>
+          <td class="lineNum left" data-value="3">3</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ga ga</div>
+            <div data-side="left">
+              <div class="comment-thread">
+                <div class="gr-formatted-text message">
+                  <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
+                </div>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="3">3</td>
+        </tr>
+        <tr class="diff-row">
+          <td class="blame" data-line-number="4"></td>
+          <td class="lineNum left" data-value="4">4</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ga ga</div>
+            <div data-side="left">
+              <div class="comment-thread">
+                <textarea data-side="right">test for textarea copying</textarea>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="4">4</td>
+        </tr>
+        <tr class="not-diff-row">
+          <td class="other">
+            <div class="contentText" data-side="right">some other text</div>
+          </td>
+        </tr>
+      </table>
+    </gr-diff-selection>
+`);
+/* eslint-enable max-len */
+
+suite('gr-diff-selection', () => {
+  let element;
+
+  const emulateCopyOn = function(target) {
+    const fakeEvent = {
+      target,
+      preventDefault: sinon.stub(),
+      clipboardData: {
+        setData: sinon.stub(),
+      },
+    };
+    element._getCopyEventTarget.returns(target);
+    element._handleCopy(fakeEvent);
+    return fakeEvent;
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    sinon.stub(element, '_getCopyEventTarget');
+    element._cachedDiffBuilder = {
+      getLineElByChild: sinon.stub().returns({}),
+      getSideByLineEl: sinon.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'],
+        },
+      ],
+    };
+  });
+
+  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);
+    sinon.stub(element, '_elementDescendedFromClass').callsFake(
+        (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', () => {
+    sinon.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');
+    sinon.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('div.contentText'));
+    assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
+  });
+
+  test('reacts to copy for content Elements', () => {
+    sinon.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('div.contentText'));
+    assert.isTrue(element._getSelectedText.called);
+  });
+
+  test('copy event is prevented for content Elements', () => {
+    sinon.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', () => {
+    sinon.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 = sinon.stub(element.classList, 'add');
+    const removeStub = sinon.stub(element.classList, 'remove').callsFake(
+        () => {
+          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 = sinon.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.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 = {};
+    flush();
+    assert.deepEqual(element._linesCache, {left: null, right: null});
+  });
+});
+
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
deleted file mode 100644
index e434e65..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ /dev/null
@@ -1,1312 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
-const MSG_LOADING_BLAME = 'Loading blame...';
-const MSG_LOADED_BLAME = 'Blame loaded';
-
-const PARENT = 'PARENT';
-
-const DiffSides = {
-  LEFT: 'left',
-  RIGHT: 'right',
-};
-
-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 {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      /**
-       * @type {{ diffMode: (string|undefined) }}
-       */
-      changeViewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-        observer: '_changeViewStateChanged',
-      },
-      disableDiffPrefs: {
-        type: Boolean,
-        value: false,
-      },
-      _diffPrefsDisabled: {
-        type: Boolean,
-        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-      },
-      /** @type {?} */
-      _patchRange: Object,
-      /** @type {?} */
-      _commitRange: Object,
-      /**
-       * @type {{
-       *  subject: string,
-       *  project: string,
-       *  revisions: string,
-       * }}
-       */
-      _change: Object,
-      /** @type {?} */
-      _changeComments: Object,
-      _changeNum: String,
-      /**
-       * This is a DiffInfo object.
-       * This is retrieved and owned by a child component.
-       */
-      _diff: Object,
-      // An array specifically formatted to be used in a gr-dropdown-list
-      // element for selected a file to view.
-      _formattedFiles: {
-        type: Array,
-        computed: '_formatFilesForDropdown(_files, ' +
-          '_patchRange.patchNum, _changeComments)',
-      },
-      // An sorted array of files, as returned by the rest API.
-      _fileList: {
-        type: Array,
-        computed: '_getSortedFileList(_files)',
-      },
-      /**
-       * Contains information about files as returned by the rest API.
-       *
-       * @type {{ sortedFileList: Array<string>, changeFilesByPath: Object }}
-       */
-      _files: {
-        type: Object,
-        value() { return {sortedFileList: [], changeFilesByPath: {}}; },
-      },
-
-      _path: {
-        type: String,
-        observer: '_pathChanged',
-      },
-      _fileNum: {
-        type: Number,
-        computed: '_computeFileNum(_path, _formattedFiles)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _prefs: Object,
-      _localPrefs: Object,
-      _projectConfig: Object,
-      _userPrefs: Object,
-      _diffMode: {
-        type: String,
-        computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
-      },
-      _isImageDiff: Boolean,
-      _filesWeblinks: Object,
-
-      /**
-       * Map of paths in the current change and patch range that have comments
-       * or drafts or robot comments.
-       */
-      _commentMap: Object,
-
-      _commentsForDiff: Object,
-
-      /**
-       * Object to contain the path of the next and previous file in the current
-       * change and patch range that has comments.
-       */
-      _commentSkips: {
-        type: Object,
-        computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
-      },
-      _panelFloatingDisabled: {
-        type: Boolean,
-        value: () => window.PANEL_FLOATING_DISABLED,
-      },
-      _editMode: {
-        type: Boolean,
-        computed: '_computeEditMode(_patchRange.*)',
-      },
-      _isBlameLoaded: Boolean,
-      _isBlameLoading: {
-        type: Boolean,
-        value: false,
-      },
-      _allPatchSets: {
-        type: Array,
-        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
-      },
-      _revisionInfo: {
-        type: Object,
-        computed: '_getRevisionInfo(_change)',
-      },
-      _reviewedFiles: {
-        type: Object,
-        value: () => new Set(),
-      },
-
-      /**
-       * 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 [
-      '_getProjectConfig(_change.project)',
-      '_getFiles(_changeNum, _patchRange.*, _changeComments)',
-      '_setReviewedObserver(_loggedIn, params.*, _prefs)',
-      '_recomputeComments(_files.changeFilesByPath,' +
-      '_path, _patchRange, _projectConfig)',
-    ];
-  }
-
-  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.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,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-
-    this.addEventListener('open-fix-preview',
-        this._onOpenFixPreview.bind(this));
-    this.$.cursor.push('diffs', this.$.diffHost);
-
-    const onRender = () => {
-      this.$.diffHost.removeEventListener('render', onRender);
-      this.$.cursor.reInitCursor();
-    };
-    this.$.diffHost.addEventListener('render', onRender);
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getProjectConfig(project) {
-    return this.$.restAPI.getProjectConfig(project).then(
-        config => {
-          this._projectConfig = config;
-        });
-  }
-
-  _getChangeDetail(changeNum) {
-    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
-      this._change = change;
-      return change;
-    });
-  }
-
-  _getChangeEdit(changeNum) {
-    return this.$.restAPI.getChangeEdit(this._changeNum);
-  }
-
-  _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;
-    });
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _getWindowWidth() {
-    return window.innerWidth;
-  }
-
-  _handleReviewedChange(e) {
-    this._setReviewed(dom(e).rootTarget.checked);
-  }
-
-  _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);
-  }
-
-  _handleToggleFileReviewed(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._setReviewed(!this.$.reviewed.checked);
-  }
-
-  _handleEscKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = false;
-  }
-
-  _handleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.moveLeft();
-  }
-
-  _handleRightPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.moveRight();
-  }
-
-  _handlePrevLineOrFileWithComments(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    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();
-  }
-
-  _handleVisibleLine(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.moveToVisibleArea();
-  }
-
-  _onOpenFixPreview(e) {
-    this.$.applyFixDialog.open(e);
-  }
-
-  _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; }
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = true;
-    this.$.cursor.moveDown();
-  }
-
-  _moveToPreviousFileWithComment() {
-    if (!this._commentSkips) { return; }
-
-    // If there is no previous diff with comments, then return to the change
-    // view.
-    if (!this._commentSkips.previous) {
-      this._navToChangeView();
-      return;
-    }
-
-    GerritNav.navigateToDiff(this._change, this._commentSkips.previous,
-        this._patchRange.patchNum, this._patchRange.basePatchNum);
-  }
-
-  _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;
-    }
-
-    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.$.diffHost.toggleLeftDiff();
-      return;
-    }
-
-    if (this.modifierPressed(e)) { return; }
-
-    if (!this._loggedIn) { 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;
-    }
-
-    GerritNav.navigateToDiff(this._change, newPath.path,
-        this._patchRange.patchNum, this._patchRange.basePatchNum);
-  }
-
-  /**
-   * @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; }
-
-    if (newPath.up) {
-      return this._getChangePath(
-          this._change,
-          this._patchRange,
-          this._change && this._change.revisions);
-    }
-    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
-  }
-
-  _goToEditFile() {
-    // TODO(taoalpha): add a shortcut for editing
-    const editUrl = GerritNav.getEditUrlForDiff(
-        this._change, this._path, this._patchRange.patchNum);
-    return GerritNav.navigateToRelativeUrl(editUrl);
-  }
-
-  /**
-   * 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; }
-
-    let idx = fileList.indexOf(path);
-    if (idx === -1) {
-      const file = direction > 0 ?
-        fileList[0] :
-        fileList[fileList.length - 1];
-      return {path: file};
-    }
-
-    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};
-    }
-
-    return {path: fileList[idx]};
-  }
-
-  _getReviewedFiles(changeNum, patchNum) {
-    return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
-        .then(files => {
-          this._reviewedFiles = new Set(files);
-          return this._reviewedFiles;
-        });
-  }
-
-  _getReviewedStatus(editMode, changeNum, patchNum, path) {
-    if (editMode) { return Promise.resolve(false); }
-    return this._getReviewedFiles(changeNum, patchNum)
-        .then(files => files.has(path));
-  }
-
-  _paramsChanged(value) {
-    if (value.view !== GerritNav.View.DIFF) { return; }
-
-    if (value.changeNum && value.project) {
-      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
-    }
-
-    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.dispatchEvent(new CustomEvent('title-change', {
-        detail: {title: this.computeTruncatedPath(this._path)},
-        composed: true, bubbles: true,
-      }));
-    });
-
-    // 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};
-      }
-    }));
-
-    promises.push(this._loadComments());
-
-    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();
-        });
-  }
-
-  _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;
-    }
-
-    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 === GerritNav.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.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;
-      }
-      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.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
new file mode 100644
index 0000000..fc46123
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -0,0 +1,1962 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-dropdown/iron-dropdown';
+import '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/revision-info/revision-info';
+import '../gr-comment-api/gr-comment-api';
+import '../gr-diff-cursor/gr-diff-cursor';
+import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-diff-host/gr-diff-host';
+import '../gr-diff-mode-selector/gr-diff-mode-selector';
+import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../gr-patch-range-select/gr-patch-range-select';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-view_html';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  patchNumEquals,
+  PatchSet,
+} from '../../../utils/patch-set-util';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  computeTruncatedPath,
+  isMagicPath,
+  specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
+import {
+  DropdownItem,
+  GrDropdownList,
+} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {
+  ChangeComments,
+  GrCommentApi,
+  TwoSidesComments,
+} from '../gr-comment-api/gr-comment-api';
+import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
+import {
+  ChangeInfo,
+  CommitId,
+  ConfigInfo,
+  DiffInfo,
+  DiffPreferencesInfo,
+  EditInfo,
+  EditPatchSetNum,
+  ElementPropertyDeepChange,
+  FileInfo,
+  NumericChangeId,
+  ParentPatchSetNum,
+  PatchRange,
+  PatchSetNum,
+  PreferencesInfo,
+  RepoName,
+  RevisionInfo,
+} from '../../../types/common';
+import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
+import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
+import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {LineOfInterest} from '../gr-diff/gr-diff';
+import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
+import {CommentMap} from '../../../utils/comment-util';
+import {AppElementParams} from '../../gr-app-types';
+import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
+
+const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+const MSG_LOADING_BLAME = 'Loading blame...';
+const MSG_LOADED_BLAME = 'Blame loaded';
+
+interface Files {
+  sortedFileList: string[];
+  changeFilesByPath: {[path: string]: FileInfo};
+}
+
+interface CommentSkips {
+  previous: string | null;
+  next: string | null;
+}
+
+export interface GrDiffView {
+  $: {
+    restAPI: RestApiService & Element;
+    commentAPI: GrCommentApi;
+    cursor: GrDiffCursor;
+    diffHost: GrDiffHost;
+    reviewed: HTMLInputElement;
+    dropdown: GrDropdownList;
+    diffPreferencesDialog: GrOverlay;
+    applyFixDialog: GrApplyFixDialog;
+    modeSelect: GrDiffModeSelector;
+  };
+}
+
+@customElement('gr-diff-view')
+export class GrDiffView extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * 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
+   */
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementParams;
+
+  @property({type: Object})
+  keyEventTarget: HTMLElement = document.body;
+
+  @property({type: Object, notify: true, observer: '_changeViewStateChanged'})
+  changeViewState: Partial<ChangeViewState> = {};
+
+  @property({type: Boolean})
+  disableDiffPrefs = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+  })
+  _diffPrefsDisabled?: boolean;
+
+  @property({type: Object})
+  _patchRange?: PatchRange;
+
+  @property({type: Object})
+  _commitRange?: CommitRange;
+
+  @property({type: Object})
+  _change?: ChangeInfo;
+
+  @property({type: Object})
+  _changeComments?: ChangeComments;
+
+  @property({type: String})
+  _changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  _diff?: DiffInfo;
+
+  @property({
+    type: Array,
+    computed:
+      '_formatFilesForDropdown(_files, ' +
+      '_patchRange.patchNum, _changeComments)',
+  })
+  _formattedFiles?: DropdownItem[];
+
+  @property({type: Array, computed: '_getSortedFileList(_files)'})
+  _fileList?: string[];
+
+  @property({type: Object})
+  _files: Files = {sortedFileList: [], changeFilesByPath: {}};
+
+  @property({type: Object, computed: '_getCurrentFile(_files, _path)'})
+  _file?: FileInfo;
+
+  @property({type: String, observer: '_pathChanged'})
+  _path?: string;
+
+  @property({type: Number, computed: '_computeFileNum(_path, _formattedFiles)'})
+  _fileNum?: number;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Object})
+  _prefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({type: Object})
+  _userPrefs?: PreferencesInfo;
+
+  @property({
+    type: String,
+    computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
+  })
+  _diffMode?: string;
+
+  @property({type: Boolean})
+  _isImageDiff?: boolean;
+
+  @property({type: Object})
+  _filesWeblinks?: FilesWebLinks;
+
+  @property({type: Object})
+  _commentMap?: CommentMap;
+
+  @property({type: Object})
+  _commentsForDiff?: TwoSidesComments;
+
+  @property({
+    type: Object,
+    computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
+  })
+  _commentSkips?: CommentSkips;
+
+  @property({type: Boolean, computed: '_computeEditMode(_patchRange.*)'})
+  _editMode?: boolean;
+
+  @property({type: Boolean})
+  _isBlameLoaded?: boolean;
+
+  @property({type: Boolean})
+  _isBlameLoading = false;
+
+  @property({
+    type: Array,
+    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+  })
+  _allPatchSets?: PatchSet[] = [];
+
+  @property({type: Object, computed: '_getRevisionInfo(_change)'})
+  _revisionInfo?: RevisionInfoObj;
+
+  @property({type: Object})
+  _reviewedFiles = new Set<string>();
+
+  @property({type: Number})
+  _focusLineNum?: number;
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+    };
+  }
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+      [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+      [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
+      [Shortcut.NEXT_FILE_WITH_COMMENTS]: '_handleNextLineOrFileWithComments',
+      [Shortcut.PREV_FILE_WITH_COMMENTS]: '_handlePrevLineOrFileWithComments',
+      [Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+      [Shortcut.NEXT_FILE]: '_handleNextFile',
+      [Shortcut.PREV_FILE]: '_handlePrevFile',
+      [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+      [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+      [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+      [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
+      [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+      [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
+      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+      [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+      [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
+      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+        '_handleToggleHideAllCommentThreads',
+      [Shortcut.OPEN_FILE_LIST]: '_handleOpenFileList',
+      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+
+      // Final two are actually handled by gr-comment-thread.
+      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
+
+  reporting = appContext.reportingService;
+
+  flagsService = appContext.flagsService;
+
+  _throttledToggleFileReviewed?: EventListener;
+
+  _onRenderHandler?: EventListener;
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    this._throttledToggleFileReviewed = this._throttleWrap(e =>
+      this._handleToggleFileReviewed(e as CustomKeyboardEvent)
+    );
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+
+    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+    this.$.cursor.push('diffs', this.$.diffHost);
+    this._onRenderHandler = (_: Event) => {
+      this.$.cursor.reInitCursor();
+    };
+    this.$.diffHost.addEventListener('render', this._onRenderHandler);
+  }
+
+  /** @override */
+  detached() {
+    if (this._onRenderHandler) {
+      this.$.diffHost.removeEventListener('render', this._onRenderHandler);
+    }
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  @observe('_change.project')
+  _getProjectConfig(project?: RepoName) {
+    if (!project) return;
+    return this.$.restAPI.getProjectConfig(project).then(config => {
+      this._projectConfig = config;
+    });
+  }
+
+  _getChangeDetail(changeNum: NumericChangeId) {
+    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+      if (!change) throw new Error('Missing "change" in API response.');
+      this._change = change;
+      return change;
+    });
+  }
+
+  _getChangeEdit() {
+    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    return this.$.restAPI.getChangeEdit(this._changeNum);
+  }
+
+  _getSortedFileList(files?: Files) {
+    if (!files) return [];
+    return files.sortedFileList;
+  }
+
+  _getCurrentFile(files?: Files, path?: string) {
+    if (!files || !path) return;
+    const fileInfo = files.changeFilesByPath[path];
+    const fileRange: FileRange = {path};
+    if (fileInfo && fileInfo.old_path) {
+      fileRange.basePath = fileInfo.old_path;
+    }
+    return fileRange;
+  }
+
+  @observe('_changeNum', '_patchRange.*', '_changeComments')
+  _getFiles(
+    changeNum: NumericChangeId,
+    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+    changeComments: ChangeComments
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      [changeNum, patchRangeRecord, patchRangeRecord.base, changeComments].some(
+        arg => arg === undefined
+      )
+    ) {
+      return Promise.resolve();
+    }
+
+    if (!patchRangeRecord.base.patchNum) {
+      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 = {...changeFiles};
+        addUnmodifiedFiles(files, commentedPaths);
+        this._files = {
+          sortedFileList: Object.keys(files).sort(specialFilePathCompare),
+          changeFilesByPath: files,
+        };
+      });
+  }
+
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences().then(prefs => {
+      this._prefs = prefs;
+    });
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _getWindowWidth() {
+    return window.innerWidth;
+  }
+
+  _handleReviewedChange(e: Event) {
+    this._setReviewed(
+      ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
+    );
+  }
+
+  _setReviewed(reviewed: boolean) {
+    if (this._editMode) return;
+    this.$.reviewed.checked = reviewed;
+    if (!this._patchRange?.patchNum) return;
+    this._saveReviewedState(reviewed).catch(err => {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: ERR_REVIEW_STATUS},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      throw err;
+    });
+  }
+
+  _saveReviewedState(reviewed: boolean): Promise<Response | undefined> {
+    if (!this._changeNum) return Promise.resolve(undefined);
+    if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
+    if (!this._path) return Promise.resolve(undefined);
+    return this.$.restAPI.saveFileReviewed(
+      this._changeNum,
+      this._patchRange?.patchNum,
+      this._path,
+      reviewed
+    );
+  }
+
+  _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this._setReviewed(!this.$.reviewed.checked);
+  }
+
+  _handleEscKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this.$.diffHost.displayLine = false;
+  }
+
+  _handleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.moveLeft();
+  }
+
+  _handleRightPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.moveRight();
+  }
+
+  _handlePrevLineOrFileWithComments(e: CustomKeyboardEvent) {
+    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();
+  }
+
+  _handleVisibleLine(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.moveToVisibleArea();
+  }
+
+  _onOpenFixPreview(e: OpenFixPreviewEvent) {
+    this.$.applyFixDialog.open(e);
+  }
+
+  _handleNextLineOrFileWithComments(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    if (
+      e.detail.keyboardEvent?.shiftKey &&
+      e.detail.keyboardEvent?.keyCode === 74
+    ) {
+      // 'J'
+      this._moveToNextFileWithComment();
+      return;
+    }
+    if (this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffHost.displayLine = true;
+    this.$.cursor.moveDown();
+  }
+
+  _moveToPreviousFileWithComment() {
+    if (!this._commentSkips) return;
+    if (!this._change) return;
+    if (!this._patchRange?.patchNum) return;
+
+    // If there is no previous diff with comments, then return to the change
+    // view.
+    if (!this._commentSkips.previous) {
+      this._navToChangeView();
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      this._commentSkips.previous,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _moveToNextFileWithComment() {
+    if (!this._commentSkips) return;
+    if (!this._change) return;
+    if (!this._patchRange?.patchNum) return;
+
+    // If there is no next diff with comments, then return to the change view.
+    if (!this._commentSkips.next) {
+      this._navToChangeView();
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      this._commentSkips.next,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleNewComment(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.createCommentInPlace();
+  }
+
+  _handlePrevFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.getKeyboardEvent(e).metaKey) return;
+    if (!this._path) return;
+    if (!this._fileList) return;
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, -1);
+  }
+
+  _handleNextFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.getKeyboardEvent(e).metaKey) return;
+    if (!this._path) return;
+    if (!this._fileList) return;
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, 1);
+  }
+
+  _handleNextChunkOrCommentThread(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent?.shiftKey) {
+      this.$.cursor.moveToNextCommentThread();
+    } else {
+      if (this.modifierPressed(e)) return;
+      // navigate to next file if key is not being held down
+      this.$.cursor.moveToNextChunk(
+        /* opt_clipToTop = */ false,
+        /* opt_navigateToNextFile = */ !e.detail.keyboardEvent?.repeat
+      );
+    }
+  }
+
+  _handlePrevChunkOrCommentThread(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent?.shiftKey) {
+      this.$.cursor.moveToPreviousCommentThread();
+    } else {
+      if (this.modifierPressed(e)) return;
+      this.$.cursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleOpenReplyDialog(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+    if (!this._loggedIn) return;
+
+    this.set('changeViewState.showReplyDialog', true);
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleToggleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!e.detail.keyboardEvent?.shiftKey) return;
+
+    e.preventDefault();
+    this.$.diffHost.toggleLeftDiff();
+  }
+
+  _handleOpenDownloadDialog(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    this.set('changeViewState.showDownloadDialog', true);
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleUpToChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleCommaKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+    if (this._diffPrefsDisabled) return;
+
+    e.preventDefault();
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _handleToggleDiffMode(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(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);
+    }
+  }
+
+  _navToChangeView() {
+    if (!this._changeNum || !this._patchRange?.patchNum) {
+      return;
+    }
+    this._navigateToChange(
+      this._change,
+      this._patchRange,
+      this._change && this._change.revisions
+    );
+  }
+
+  _navToFile(path: string, fileList: string[], direction: -1 | 1) {
+    const newPath = this._getNavLinkPath(path, fileList, direction);
+    if (!newPath) return;
+    if (!this._change) return;
+    if (!this._patchRange) return;
+
+    if (newPath.up) {
+      this._navigateToChange(
+        this._change,
+        this._patchRange,
+        this._change && this._change.revisions
+      );
+      return;
+    }
+
+    if (!newPath.path) return;
+    GerritNav.navigateToDiff(
+      this._change,
+      newPath.path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  /**
+   * @param path The path of the current file being shown.
+   * @param fileList The list of files in this change and
+   * patch range.
+   * @param direction Either 1 (next file) or -1 (prev file).
+   * @param opt_noUp Whether to return to the change view
+   * when advancing the file goes outside the bounds of fileList.
+   * @return The next URL when proceeding in the specified
+   * direction.
+   */
+  _computeNavLinkURL(
+    change?: ChangeInfo,
+    path?: string,
+    fileList?: string[],
+    direction?: -1 | 1,
+    opt_noUp?: boolean
+  ) {
+    if (!change) return null;
+    if (!path) return null;
+    if (!fileList) return null;
+    if (!direction) return null;
+
+    const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+    if (!newPath) {
+      return null;
+    }
+
+    if (newPath.up) {
+      return this._getChangePath(
+        this._change,
+        this._patchRange,
+        this._change && this._change.revisions
+      );
+    }
+    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+  }
+
+  _goToEditFile() {
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    // TODO(taoalpha): add a shortcut for editing
+    const cursorAddress = this.$.cursor.getAddress();
+    const editUrl = GerritNav.getEditUrlForDiff(
+      this._change,
+      this._path,
+      this._patchRange.patchNum,
+      cursorAddress?.number
+    );
+    GerritNav.navigateToRelativeUrl(editUrl);
+  }
+
+  /**
+   * 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 path The path of the current file being shown.
+   * @param fileList The list of files in this change and
+   * patch range.
+   * @param direction Either 1 (next file) or -1 (prev file).
+   * @param opt_noUp Whether to return to the change view
+   * when advancing the file goes outside the bounds of fileList.
+   */
+  _getNavLinkPath(
+    path: string,
+    fileList: string[],
+    direction: -1 | 1,
+    opt_noUp?: boolean
+  ) {
+    if (!path || !fileList || fileList.length === 0) {
+      return null;
+    }
+
+    let idx = fileList.indexOf(path);
+    if (idx === -1) {
+      const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
+      return {path: file};
+    }
+
+    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};
+    }
+
+    return {path: fileList[idx]};
+  }
+
+  _getReviewedFiles(
+    changeNum?: NumericChangeId,
+    patchNum?: PatchSetNum
+  ): Promise<Set<string>> {
+    if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
+    return this.$.restAPI.getReviewedFiles(changeNum, patchNum).then(files => {
+      this._reviewedFiles = new Set(files);
+      return this._reviewedFiles;
+    });
+  }
+
+  _getReviewedStatus(
+    editMode?: boolean,
+    changeNum?: NumericChangeId,
+    patchNum?: PatchSetNum,
+    path?: string
+  ) {
+    if (editMode || !path) {
+      return Promise.resolve(false);
+    }
+    return this._getReviewedFiles(changeNum, patchNum).then(files =>
+      files.has(path)
+    );
+  }
+
+  _initLineOfInterestAndCursor(leftSide: boolean) {
+    this.$.diffHost.lineOfInterest = this._getLineOfInterest(leftSide);
+    this._initCursor(leftSide);
+  }
+
+  _displayDiffBaseAgainstLeftToast() {
+    if (!this._patchRange) return;
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {
+          // \u2190 = ←
+          message:
+            `Patchset ${this._patchRange.basePatchNum} vs ` +
+            `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
+            `Base vs ${this._patchRange.basePatchNum}`,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
+    if (!this._patchRange) return;
+    const leftPatchset = patchNumEquals(
+      this._patchRange.basePatchNum,
+      ParentPatchSetNum
+    )
+      ? 'Base'
+      : `Patchset ${this._patchRange.basePatchNum}`;
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {
+          // \u2191 = ↑
+          message: `${leftPatchset} vs
+            ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
+            ${leftPatchset} vs Patchset ${latestPatchNum}`,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _displayToasts() {
+    if (!this._patchRange) return;
+    if (!patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this._displayDiffBaseAgainstLeftToast();
+      return;
+    }
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this._displayDiffAgainstLatestToast(latestPatchNum);
+      return;
+    }
+  }
+
+  _initCommitRange() {
+    let commit: CommitId | undefined;
+    let baseCommit: CommitId | undefined;
+    if (!this._change) return;
+    if (!this._patchRange || !this._patchRange.patchNum) return;
+    for (const commitSha in this._change.revisions) {
+      if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
+      const revision = this._change.revisions[commitSha];
+      const patchNum = revision._number;
+      if (patchNumEquals(patchNum, this._patchRange.patchNum)) {
+        commit = commitSha as CommitId;
+        const commitObj = revision.commit;
+        const parents = commitObj?.parents || [];
+        if (
+          patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum) &&
+          parents.length
+        ) {
+          baseCommit = parents[parents.length - 1].commit;
+        }
+      } else if (patchNumEquals(patchNum, this._patchRange.basePatchNum)) {
+        baseCommit = commitSha as CommitId;
+      }
+    }
+    this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
+  }
+
+  _initPatchRange() {
+    let leftSide = false;
+    if (!this._change) return;
+    if (this.params?.view !== GerritView.DIFF) return;
+    if (this.params?.commentId) {
+      const comment = this._changeComments?.findCommentById(
+        this.params.commentId
+      );
+      if (!comment) {
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {
+              message: 'comment not found',
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+        GerritNav.navigateToChange(this._change);
+        return;
+      }
+      this._path = comment.path;
+      const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+      if (!comment.patch_set) throw new Error('Missing comment.patch_set');
+      if (!latestPatchNum) throw new Error('Missing _allPatchSets');
+      if (patchNumEquals(latestPatchNum, comment.patch_set)) {
+        this._patchRange = {
+          patchNum: latestPatchNum,
+          basePatchNum: ParentPatchSetNum,
+        };
+        leftSide = comment.__commentSide === 'left';
+      } else {
+        this._patchRange = {
+          patchNum: latestPatchNum,
+          basePatchNum: comment.patch_set,
+        };
+        // comment is now on the left side since we are showing
+        // comment.patch_set vs latest
+        leftSide = true;
+      }
+      this._focusLineNum = comment.line;
+    } else {
+      if (this.params.path) {
+        this._path = this.params.path;
+      }
+      if (this.params.patchNum) {
+        this._patchRange = {
+          patchNum: this.params.patchNum,
+          basePatchNum: this.params.basePatchNum || ParentPatchSetNum,
+        };
+      }
+      if (this.params.lineNum) {
+        this._focusLineNum = this.params.lineNum;
+        leftSide = !!this.params.leftSide;
+      }
+    }
+    if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
+    this._initLineOfInterestAndCursor(leftSide);
+    this._commentMap = this._getPaths(this._patchRange);
+
+    this._commentsForDiff = this._getCommentsForPath(
+      this._path,
+      this._patchRange,
+      this._projectConfig
+    );
+  }
+
+  _isFileUnchanged(diff: DiffInfo) {
+    if (!diff || !diff.content) return false;
+    return !diff.content.some(
+      content =>
+        (content.a && !content.common) || (content.b && !content.common)
+    );
+  }
+
+  _paramsChanged(value: AppElementParams) {
+    if (value.view !== GerritView.DIFF) {
+      return;
+    }
+
+    this._change = undefined;
+    this._files = {sortedFileList: [], changeFilesByPath: {}};
+    this._path = undefined;
+    this._patchRange = undefined;
+    this._commitRange = undefined;
+    this._changeComments = undefined;
+    this._focusLineNum = undefined;
+
+    if (value.changeNum && value.project) {
+      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+    }
+
+    this._changeNum = value.changeNum;
+    this.classList.remove('hideComments');
+
+    // 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 route is of type /comment/<commentId>/ then no patchNum is present
+    if (!value.patchNum && !value.commentLink) {
+      console.warn('invalid url, no patchNum found');
+      return;
+    }
+
+    const promises: Promise<unknown>[] = [];
+
+    promises.push(this._getDiffPreferences());
+
+    promises.push(
+      this._getPreferences().then(prefs => {
+        this._userPrefs = prefs;
+      })
+    );
+
+    promises.push(this._getChangeDetail(this._changeNum));
+    promises.push(this._loadComments());
+
+    promises.push(this._getChangeEdit());
+
+    this.$.diffHost.cancel();
+    this.$.diffHost.clearDiffContent();
+    this._loading = true;
+    return Promise.all(promises)
+      .then(r => {
+        this._loading = false;
+        this._initPatchRange();
+        this._initCommitRange();
+        this.$.diffHost.comments = this._commentsForDiff;
+        const edit = r[4] as EditInfo | undefined;
+        if (edit) {
+          this.set(`_change.revisions.${edit.commit.commit}`, {
+            _number: EditPatchSetNum,
+            basePatchNum: edit.base_patch_set_number,
+            commit: edit.commit,
+          });
+        }
+        return this.$.diffHost.reload(true);
+      })
+      .then(() => {
+        this.reporting.diffViewFullyLoaded();
+        // If diff view displayed has not ended yet, it ends here.
+        this.reporting.diffViewDisplayed();
+      })
+      .then(() => {
+        if (!this._diff) throw new Error('Missing this._diff');
+        const fileUnchanged = this._isFileUnchanged(this._diff);
+        if (fileUnchanged && value.commentLink) {
+          if (!this._change) throw new Error('Missing this._change');
+          if (!this._path) throw new Error('Missing this._path');
+          if (!this._patchRange) throw new Error('Missing this._patchRange');
+
+          if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+            // file is unchanged between Base vs X
+            // hence should not show diff between Base vs Base
+            return;
+          }
+
+          this.dispatchEvent(
+            new CustomEvent('show-alert', {
+              detail: {
+                message: `File is unchanged between Patchset
+                  ${this._patchRange.basePatchNum} and
+                  ${this._patchRange.patchNum}. Showing diff of Base vs
+                  ${this._patchRange.basePatchNum}`,
+              },
+              composed: true,
+              bubbles: true,
+            })
+          );
+          GerritNav.navigateToDiff(
+            this._change,
+            this._path,
+            this._patchRange.basePatchNum,
+            ParentPatchSetNum,
+            this._focusLineNum
+          );
+          return;
+        }
+        if (value.commentLink) {
+          this._displayToasts();
+        }
+        // If the blame was loaded for a previous file and user navigates to
+        // another file, then we load the blame for this file too
+        if (this._isBlameLoaded) this._loadBlame();
+      });
+  }
+
+  _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
+    if (changeViewState.diffMode === null) {
+      // If screen size is small, always default to unified view.
+      this.$.restAPI.getPreferences().then(prefs => {
+        if (prefs) {
+          this.set('changeViewState.diffMode', prefs.default_diff_view);
+        }
+      });
+    }
+  }
+
+  @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+  _setReviewedObserver(
+    _loggedIn?: boolean,
+    paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
+    _prefs?: DiffPreferencesInfo,
+    patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
+  ) {
+    if (_loggedIn === undefined) return;
+    if (paramsRecord === undefined) return;
+    if (_prefs === undefined) return;
+    if (patchRangeRecord === undefined) return;
+    if (patchRangeRecord.base === undefined) return;
+
+    const patchRange = patchRangeRecord.base;
+    if (!_loggedIn) {
+      return;
+    }
+
+    if (_prefs.manual_review) {
+      // Checkbox state needs to be set explicitly only when manual_review
+      // is specified.
+
+      if (patchRange.patchNum) {
+        this._getReviewedStatus(
+          this._editMode,
+          this._changeNum,
+          patchRange.patchNum,
+          this._path
+        ).then((status: boolean) => {
+          this.$.reviewed.checked = status;
+        });
+      }
+      return;
+    }
+
+    if (paramsRecord.base?.view === GerritNav.View.DIFF) {
+      this._setReviewed(true);
+    }
+  }
+
+  /**
+   * If the params specify a diff address then configure the diff cursor.
+   */
+  _initCursor(leftSide: boolean) {
+    if (this._focusLineNum === undefined) {
+      return;
+    }
+    if (leftSide) {
+      this.$.cursor.side = Side.LEFT;
+    } else {
+      this.$.cursor.side = Side.RIGHT;
+    }
+    this.$.cursor.initialLineNumber = this._focusLineNum;
+  }
+
+  _getLineOfInterest(leftSide: boolean): LineOfInterest | undefined {
+    // If there is a line number specified, pass it along to the diff so that
+    // it will not get collapsed.
+    if (!this._focusLineNum) {
+      return undefined;
+    }
+
+    return {number: this._focusLineNum, leftSide};
+  }
+
+  _pathChanged(path: string) {
+    if (path) {
+      this.dispatchEvent(
+        new CustomEvent('title-change', {
+          detail: {title: computeTruncatedPath(path)},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
+
+    if (!this._fileList || this._fileList.length === 0) return;
+
+    this.set('changeViewState.selectedFileIndex', this._fileList.indexOf(path));
+  }
+
+  _getDiffUrl(change?: ChangeInfo, patchRange?: PatchRange, path?: string) {
+    if (!change || !patchRange || !path) return '';
+    return GerritNav.getUrlForDiff(
+      change,
+      path,
+      patchRange.patchNum,
+      patchRange.basePatchNum
+    );
+  }
+
+  _patchRangeStr(patchRange: PatchRange) {
+    let patchStr = `${patchRange.patchNum}`;
+    if (
+      patchRange.basePatchNum &&
+      patchRange.basePatchNum !== ParentPatchSetNum
+    ) {
+      patchStr = `${patchRange.basePatchNum}..${patchRange.patchNum}`;
+    }
+    return patchStr;
+  }
+
+  /**
+   * When the latest patch of the change is selected (and there is no base
+   * patch) then the patch range need not appear in the URL. Return a patch
+   * range object with undefined values when a range is not needed.
+   */
+  _getChangeUrlRange(
+    patchRange?: PatchRange,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    let patchNum = undefined;
+    let basePatchNum = undefined;
+    let latestPatchNum = -1;
+    for (const rev of Object.values(revisions || {})) {
+      if (typeof rev._number === 'number') {
+        latestPatchNum = Math.max(latestPatchNum, rev._number);
+      }
+    }
+    if (!patchRange) return {patchNum, basePatchNum};
+    if (
+      patchRange.basePatchNum !== ParentPatchSetNum ||
+      !patchNumEquals(patchRange.patchNum, latestPatchNum as PatchSetNum)
+    ) {
+      patchNum = patchRange.patchNum;
+      basePatchNum = patchRange.basePatchNum;
+    }
+    return {patchNum, basePatchNum};
+  }
+
+  _getChangePath(
+    change?: ChangeInfo,
+    patchRange?: PatchRange,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    if (!change) return '';
+    if (!patchRange) return '';
+
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    return GerritNav.getUrlForChange(
+      change,
+      range.patchNum,
+      range.basePatchNum
+    );
+  }
+
+  _navigateToChange(
+    change?: ChangeInfo,
+    patchRange?: PatchRange,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    if (!change) return;
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
+  }
+
+  _computeChangePath(
+    change?: ChangeInfo,
+    patchRangeRecord?: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    if (!patchRangeRecord) return '';
+    return this._getChangePath(change, patchRangeRecord.base, revisions);
+  }
+
+  _formatFilesForDropdown(
+    files?: Files,
+    patchNum?: PatchSetNum,
+    changeComments?: ChangeComments
+  ): DropdownItem[] {
+    if (!files) return [];
+    if (!patchNum) return [];
+    if (!changeComments) return [];
+
+    const dropdownContent: DropdownItem[] = [];
+    for (const path of files.sortedFileList) {
+      dropdownContent.push({
+        text: computeDisplayPath(path),
+        mobileText: computeTruncatedPath(path),
+        value: path,
+        bottomText: this._computeCommentString(
+          changeComments,
+          patchNum,
+          path,
+          files.changeFilesByPath[path]
+        ),
+      });
+    }
+    return dropdownContent;
+  }
+
+  _computeCommentString(
+    changeComments?: ChangeComments,
+    patchNum?: PatchSetNum,
+    path?: string,
+    changeFileInfo?: FileInfo
+  ) {
+    if (!changeComments) return '';
+    if (!path) return '';
+    if (!changeFileInfo) return '';
+
+    const unresolvedCount = changeComments.computeUnresolvedNum({
+      patchNum,
+      path,
+    });
+    const commentThreadCount = changeComments.computeCommentThreadCount({
+      patchNum,
+      path,
+    });
+    const commentThreadString = GrCountStringFormatter.computePluralString(
+      commentThreadCount,
+      'comment'
+    );
+    const unresolvedString = GrCountStringFormatter.computeString(
+      unresolvedCount,
+      'unresolved'
+    );
+
+    const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes' : '';
+
+    return [unmodifiedString, commentThreadString, unresolvedString]
+      .filter(v => v && v.length > 0)
+      .join(', ');
+  }
+
+  _computePrefsButtonHidden(
+    prefs?: DiffPreferencesInfo,
+    prefsDisabled?: boolean
+  ) {
+    return prefsDisabled || !prefs;
+  }
+
+  _handleFileChange(e: CustomEvent) {
+    if (!this._change) return;
+    if (!this._patchRange) return;
+
+    // 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
+    );
+  }
+
+  _handlePatchChange(e: CustomEvent) {
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const {basePatchNum, patchNum} = e.detail;
+    if (
+      patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
+      patchNumEquals(patchNum, this._patchRange.patchNum)
+    ) {
+      return;
+    }
+    GerritNav.navigateToDiff(this._change, this._path, patchNum, basePatchNum);
+  }
+
+  _handlePrefsTap(e: Event) {
+    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.
+   */
+  _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(diff?: DiffInfo) {
+    return !diff || diff.binary ? 'hide' : '';
+  }
+
+  _onLineSelected(
+    _: Event,
+    detail: {side: Side | CommentSide; number: number}
+  ) {
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._changeNum) return;
+    if (!this._patchRange) return;
+
+    const number = detail.number;
+    // for on-comment-anchor-tap side can be PARENT/REVISIONS
+    // for on-line-selected side can be left/right
+    const leftSide =
+      detail.side === Side.LEFT || detail.side === CommentSide.PARENT;
+    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?: RepoName,
+    changeNum?: NumericChangeId,
+    patchRange?: PatchRange,
+    path?: string,
+    diff?: DiffInfo
+  ) {
+    if (!project) return [];
+    if (!changeNum) return [];
+    if (!patchRange || !patchRange.patchNum) return [];
+    if (!path) 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: RepoName,
+    changeNum: NumericChangeId,
+    patchRange: PatchRange,
+    path: string,
+    isBase?: boolean
+  ) {
+    let patchNum = patchRange.patchNum;
+
+    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+
+    if (isBase && !comparedAgainsParent) {
+      patchNum = patchRange.basePatchNum;
+    }
+
+    let url =
+      changeBaseURL(project, changeNum, patchNum) +
+      `/files/${encodeURIComponent(path)}/download`;
+
+    if (isBase && comparedAgainsParent) {
+      url += '?parent=1';
+    }
+
+    return url;
+  }
+
+  _computeDownloadPatchLink(
+    project: RepoName,
+    changeNum: NumericChangeId,
+    patchRange: PatchRange,
+    path: string
+  ) {
+    let url = changeBaseURL(project, changeNum, patchRange.patchNum);
+    url += '/patch?zip&path=' + encodeURIComponent(path);
+    return url;
+  }
+
+  _loadComments() {
+    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
+      this._changeComments = comments;
+    });
+  }
+
+  @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
+  _recomputeComments(
+    files?: {[path: string]: FileInfo},
+    path?: string,
+    patchRange?: PatchRange,
+    projectConfig?: ConfigInfo
+  ) {
+    if (!files) return;
+    if (!path) return;
+    if (!patchRange) return;
+    if (!projectConfig) return;
+    if (!this._changeComments) return;
+
+    const file = files[path];
+    if (file && file.old_path) {
+      this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
+        {path, basePath: file.old_path},
+        patchRange,
+        projectConfig
+      );
+
+      this.$.diffHost.comments = this._commentsForDiff;
+    }
+  }
+
+  _getPaths(patchRange: PatchRange) {
+    if (!this._changeComments) return {};
+    return this._changeComments.getPaths(patchRange);
+  }
+
+  _getCommentsForPath(
+    path?: string,
+    patchRange?: PatchRange,
+    projectConfig?: ConfigInfo
+  ) {
+    if (!path) return undefined;
+    if (!patchRange) return undefined;
+    if (!this._changeComments) return undefined;
+
+    return this._changeComments.getCommentsBySideForPath(
+      path,
+      patchRange,
+      projectConfig
+    );
+  }
+
+  _getDiffDrafts() {
+    if (!this._changeNum) throw new Error('Missing this._changeNum');
+
+    return this.$.restAPI.getDiffDrafts(this._changeNum);
+  }
+
+  _computeCommentSkips(
+    commentMap?: CommentMap,
+    fileList?: string[],
+    path?: string
+  ) {
+    if (!commentMap) return undefined;
+    if (!fileList) return undefined;
+    if (!path) return undefined;
+
+    const skips: CommentSkips = {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;
+  }
+
+  _computeContainerClass(editMode: boolean) {
+    return editMode ? 'editMode' : '';
+  }
+
+  _computeEditMode(
+    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+  ) {
+    const patchRange = patchRangeRecord.base || {};
+    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+  }
+
+  _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
+    return loaded && !loading ? 'Hide blame' : 'Show blame';
+  }
+
+  _loadBlame() {
+    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;
+      });
+  }
+
+  /**
+   * Load and display blame information if it has not already been loaded.
+   * Otherwise hide it.
+   */
+  _toggleBlame() {
+    if (this._isBlameLoaded) {
+      this.$.diffHost.clearBlame();
+      return;
+    }
+    this._loadBlame();
+  }
+
+  _handleToggleBlame(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    this._toggleBlame();
+  }
+
+  _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    this.toggleClass('hideComments');
+  }
+
+  _handleOpenFileList(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+    this.$.dropdown.open();
+  }
+
+  _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Base is already selected.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      this._patchRange.patchNum
+    );
+  }
+
+  _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Left is already base.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      this._patchRange.basePatchNum,
+      'PARENT' as PatchSetNum,
+      this.params?.view === GerritView.DIFF && this.params?.commentLink
+        ? this._focusLineNum
+        : undefined
+    );
+  }
+
+  _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Latest is already selected.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      latestPatchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Right is already latest.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      latestPatchNum,
+      this._patchRange.patchNum
+    );
+  }
+
+  _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (
+      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+    ) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Already diffing base against latest.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
+  }
+
+  _computeBlameLoaderClass(isImageDiff?: boolean, path?: string) {
+    return !isMagicPath(path) && !isImageDiff ? 'show' : '';
+  }
+
+  _getRevisionInfo(change: ChangeInfo) {
+    return new RevisionInfoObj(change);
+  }
+
+  _computeFileNum(file?: string, files?: DropdownItem[]) {
+    if (!file || !files) return undefined;
+
+    return files.findIndex(({value}) => value === file) + 1;
+  }
+
+  _computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
+    if (files && fileNum && fileNum > 0) {
+      return 'show';
+    }
+    return '';
+  }
+
+  _handleExpandAllDiffContext(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    this.$.diffHost.expandAllContext();
+  }
+
+  _computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
+    return disableDiffPrefs || !loggedIn;
+  }
+
+  _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._path) return;
+    if (!this._fileList) return;
+    if (!this._reviewedFiles) 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();
+  }
+
+  _computeCanEdit(
+    loggedIn?: boolean,
+    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+  ) {
+    if (!changeChangeRecord?.base) return false;
+    return loggedIn && changeIsOpen(changeChangeRecord.base);
+  }
+
+  _computeIsLoggedIn(loggedIn: boolean) {
+    return loggedIn ? true : false;
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeAllPatchSets(change: ChangeInfo) {
+    return computeAllPatchSets(change);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeDisplayPath(path: string) {
+    return computeDisplayPath(path);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeTruncatedPath(path?: string) {
+    return path ? computeTruncatedPath(path) : '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-view': 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
deleted file mode 100644
index c74a192..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
+++ /dev/null
@@ -1,424 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
new file mode 100644
index 0000000..be2dce5
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -0,0 +1,435 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      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);
+      }
+    }
+    .stickyHeader {
+      background-color: var(--view-background-color);
+      position: sticky;
+      top: 0;
+      /* TODO(dhruvsri): This is required only because of 'position:relative' in
+         <gr-diff-highlight> (which could maybe be removed??). */
+      z-index: 1;
+      box-shadow: var(--elevation-level-1);
+      /* This is just for giving the box-shadow some space. */
+      margin-bottom: 2px;
+    }
+    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: top;
+      position: relative;
+      top: 8px;
+    }
+    .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%;
+        }
+      }
+    }
+    :host(.hideComments) {
+      --gr-comment-thread-display: none;
+    }
+  </style>
+  <div class$="stickyHeader [[_computeContainerClass(_editMode)]]">
+    <h1 class="assistive-tech-only">
+      Diff of [[_computeTruncatedPath(_path)]]
+    </h1>
+    <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=""
+          title="Toggle reviewed status of file"
+          aria-label="file reviewed"
+        /><!--
+       -->
+        <div class="jumpToFileContainer">
+          <gr-dropdown-list
+            id="dropdown"
+            value="[[_path]]"
+            on-value-change="_handleFileChange"
+            items="[[_formattedFiles]]"
+            initial-count="75"
+            show-copy-for-trigger-text
+          >
+          </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(_diff)]]">
+          <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>
+  </div>
+  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+  <h2 class="assistive-tech-only">Diff view</h2>
+  <gr-diff-host
+    id="diffHost"
+    hidden=""
+    hidden$="[[_loading]]"
+    is-image-diff="{{_isImageDiff}}"
+    files-weblinks="{{_filesWeblinks}}"
+    diff="{{_diff}}"
+    change-num="[[_changeNum]]"
+    change="[[_change]]"
+    commit-range="[[_commitRange]]"
+    patch-range="[[_patchRange]]"
+    file="[[_file]]"
+    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"
+    on-navigate-to-next-unreviewed-file="_handleNextUnreviewedFile"
+  ></gr-diff-cursor>
+  <gr-comment-api id="commentAPI"></gr-comment-api>
+`;
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
deleted file mode 100644
index f5275e2..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ /dev/null
@@ -1,1471 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-diff-view</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/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-view></gr-diff-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<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');
-    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
-    kb.bindShortcut(kb.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-    kb.bindShortcut(kb.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
-    kb.bindShortcut(kb.Shortcut.SAVE_COMMENT, 'ctrl+s');
-    kb.bindShortcut(kb.Shortcut.NEXT_FILE, ']');
-    kb.bindShortcut(kb.Shortcut.PREV_FILE, '[');
-    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
-    kb.bindShortcut(kb.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
-    kb.bindShortcut(kb.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-    kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-    kb.bindShortcut(kb.Shortcut.UP_TO_CHANGE, 'u');
-    kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
-    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;
-
-    const PARENT = 'PARENT';
-
-    function getFilesFromFileList(fileList) {
-      const changeFilesByPath = fileList.reduce((files, path) => {
-        files[path] = {};
-        return files;
-      }, {});
-      return {
-        sortedFileList: fileList,
-        changeFilesByPath,
-      };
-    }
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      stub('gr-rest-api-interface', {
-        getConfig() {
-          return Promise.resolve({change: {}});
-        },
-        getLoggedIn() {
-          return Promise.resolve(false);
-        },
-        getProjectConfig() {
-          return Promise.resolve({});
-        },
-        getDiffChangeDetail() {
-          return Promise.resolve({});
-        },
-        getChangeFiles() {
-          return Promise.resolve({});
-        },
-        saveFileReviewed() {
-          return Promise.resolve();
-        },
-        getDiffComments() {
-          return Promise.resolve({});
-        },
-        getDiffRobotComments() {
-          return Promise.resolve({});
-        },
-        getDiffDrafts() {
-          return Promise.resolve({});
-        },
-        getReviewedFiles() {
-          return Promise.resolve([]);
-        },
-      });
-      element = fixture('basic');
-      return element._loadComments();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('params change triggers diffViewDisplayed()', () => {
-      sandbox.stub(element.$.reporting, 'diffViewDisplayed');
-      sandbox.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-      sandbox.spy(element, '_paramsChanged');
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
-        path: '/COMMIT_MSG',
-      };
-
-      return element._paramsChanged.returnValues[0].then(() => {
-        assert.isTrue(element.$.reporting.diffViewDisplayed.calledOnce);
-      });
-    });
-
-    test('toggle left diff with a hotkey', () => {
-      const toggleLeftDiffStub = sandbox.stub(
-          element.$.diffHost, 'toggleLeftDiff');
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-      assert.isTrue(toggleLeftDiffStub.calledOnce);
-    });
-
-    test('keyboard shortcuts', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: '10',
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 10, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-      element.changeViewState.selectedFileIndex = 1;
-      element._loggedIn = true;
-
-      const diffNavStub = sandbox.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sandbox.stub(GerritNav, 'navigateToChange');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWith(element._change),
-          'Should navigate to /c/42/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
-          '10', PARENT), 'Should navigate to /c/42/10/wheatley.md');
-      element._path = 'wheatley.md';
-      assert.equal(element.changeViewState.selectedFileIndex, 2);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
-          '10', PARENT), 'Should navigate to /c/42/10/glados.txt');
-      element._path = 'glados.txt';
-      assert.equal(element.changeViewState.selectedFileIndex, 1);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', '10',
-          PARENT), 'Should navigate to /c/42/10/chell.go');
-      element._path = 'chell.go';
-      assert.equal(element.changeViewState.selectedFileIndex, 0);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWith(element._change),
-          'Should navigate to /c/42/');
-      assert.equal(element.changeViewState.selectedFileIndex, 0);
-      assert.isTrue(element._loading);
-
-      const showPrefsStub =
-          sandbox.stub(element.$.diffPreferencesDialog, 'open',
-              () => Promise.resolve());
-
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert(showPrefsStub.calledOnce);
-
-      element.disableDiffPrefs = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert(showPrefsStub.calledOnce);
-
-      let scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sandbox.stub(element.$.cursor, 'moveToPreviousChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sandbox.stub(element.$.cursor, 'moveToNextCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sandbox.stub(element.$.cursor,
-          'moveToPreviousCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
-      assert(scrollStub.calledOnce);
-
-      const computeContainerClassStub = sandbox.stub(element.$.diffHost.$.diff,
-          '_computeContainerClass');
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, 'SIDE_BY_SIDE', true));
-
-      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
-      assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, 'SIDE_BY_SIDE', false));
-
-      sandbox.stub(element, '_setReviewed');
-      element.$.reviewed.checked = false;
-      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-      assert.isFalse(element._setReviewed.called);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      assert.isTrue(element._setReviewed.called);
-      assert.equal(element._setReviewed.lastCall.args[0], true);
-    });
-
-    test('shift+x shortcut expands all diff context', () => {
-      const expandStub = sandbox.stub(element.$.diffHost, 'expandAllContext');
-      MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
-      flushAsynchronousOperations();
-      assert.isTrue(expandStub.called);
-    });
-
-    test('keyboard shortcuts with patch range', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: '5',
-        patchNum: '10',
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 10, commit: {parents: []}},
-          b: {_number: 5, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-
-      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 ' +
-          'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(element.changeViewState.showReplyDialog);
-
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-          '5'), 'Should navigate to /c/42/5..10');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-          '5'), 'Should navigate to /c/42/5..10');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', '10', '5'),
-      'Should navigate to /c/42/5..10/wheatley.md');
-      element._path = 'wheatley.md';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', '10', '5'),
-      'Should navigate to /c/42/5..10/glados.txt');
-      element._path = 'glados.txt';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(
-          element._change,
-          'chell.go',
-          '10',
-          '5'),
-      'Should navigate to /c/42/5..10/chell.go');
-      element._path = 'chell.go';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-          '5'),
-      'Should navigate to /c/42/5..10');
-    });
-
-    test('keyboard shortcuts with old patch number', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: '1',
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-
-      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 ' +
-          'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(element.changeViewState.showReplyDialog);
-
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
-          PARENT), 'Should navigate to /c/42/1');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
-          PARENT), 'Should navigate to /c/42/1');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', '1', PARENT),
-      'Should navigate to /c/42/1/wheatley.md');
-      element._path = 'wheatley.md';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', '1', PARENT),
-      'Should navigate to /c/42/1/glados.txt');
-      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');
-      element._path = 'chell.go';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
-          PARENT), 'Should navigate to /c/42/1');
-    });
-
-    test('edit should redirect to edit page', done => {
-      element._loggedIn = true;
-      element._path = 't.txt';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: '1',
-      };
-      element._change = {
-        _number: 42,
-        status: 'NEW',
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      const redirectStub = sandbox.stub(GerritNav, 'navigateToRelativeUrl');
-      flush(() => {
-        const editBtn = element.shadowRoot
-            .querySelector('.editButton gr-button');
-        assert.isTrue(!!editBtn);
-        MockInteractions.tap(editBtn);
-        assert.isTrue(redirectStub.called);
-        done();
-      });
-    });
-
-    function isEditVisibile({loggedIn, changeStatus}) {
-      return new Promise(resolve => {
-        element._loggedIn = loggedIn;
-        element._path = 't.txt';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '1',
-        };
-        element._change = {
-          _number: 42,
-          status: changeStatus,
-          revisions: {
-            a: {_number: 1, commit: {parents: []}},
-            b: {_number: 2, commit: {parents: []}},
-          },
-        };
-        flush(() => {
-          const editBtn = element.shadowRoot
-              .querySelector('.editButton gr-button');
-          resolve(!!editBtn);
-        });
-      });
-    }
-
-    test('edit visible only when logged and status NEW', async () => {
-      for (const changeStatus in element.ChangeStatus) {
-        if (!element.ChangeStatus.hasOwnProperty(changeStatus)) {
-          continue;
-        }
-        assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
-            `loggedIn: false, changeStatus: ${changeStatus}`);
-
-        if (changeStatus !== element.ChangeStatus.NEW) {
-          assert.isFalse(await isEditVisibile({loggedIn: true, changeStatus}),
-              `loggedIn: true, changeStatus: ${changeStatus}`);
-        } else {
-          assert.isTrue(await isEditVisibile({loggedIn: true, changeStatus}),
-              `loggedIn: true, changeStatus: ${changeStatus}`);
-        }
-      }
-    });
-
-    test('edit visible when logged and status NEW', async () => {
-      assert.isTrue(await isEditVisibile(
-          {loggedIn: true, changeStatus: element.ChangeStatus.NEW}));
-    });
-
-    test('edit hidden when logged and status ABANDONED', async () => {
-      assert.isFalse(await isEditVisibile(
-          {loggedIn: true, changeStatus: element.ChangeStatus.ABANDONED}));
-    });
-
-    test('edit hidden when logged and status MERGED', async () => {
-      assert.isFalse(await isEditVisibile(
-          {loggedIn: true, changeStatus: element.ChangeStatus.MERGED}));
-    });
-
-    suite('diff prefs hidden', () => {
-      test('when no prefs or logged out', () => {
-        element.disableDiffPrefs = false;
-        element._loggedIn = false;
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = true;
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = false;
-        element._prefs = {font_size: '12'};
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = true;
-        flushAsynchronousOperations();
-        assert.isFalse(element.$.diffPrefsContainer.hidden);
-      });
-
-      test('when disableDiffPrefs is set', () => {
-        element._loggedIn = true;
-        element._prefs = {font_size: '12'};
-        element.disableDiffPrefs = false;
-        flushAsynchronousOperations();
-
-        assert.isFalse(element.$.diffPrefsContainer.hidden);
-        element.disableDiffPrefs = true;
-        flushAsynchronousOperations();
-
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-      });
-    });
-
-    test('prefsButton opens gr-diff-preferences', () => {
-      const handlePrefsTapSpy = sandbox.spy(element, '_handlePrefsTap');
-      const overlayOpenStub = sandbox.stub(element.$.diffPreferencesDialog,
-          'open');
-      const prefsButton =
-          dom(element.root).querySelector('.prefsButton');
-
-      MockInteractions.tap(prefsButton);
-
-      assert.isTrue(handlePrefsTapSpy.called);
-      assert.isTrue(overlayOpenStub.called);
-    });
-
-    test('_computeCommentString', done => {
-      const path = '/test';
-      element.$.commentAPI.loadAll().then(comments => {
-        const commentCountStub =
-            sandbox.stub(comments, 'computeCommentCount');
-        const unresolvedCountStub =
-            sandbox.stub(comments, 'computeUnresolvedNum');
-        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');
-        assert.equal(
-            element._computeCommentString(comments, 2, path, {status: 'M'}),
-            '1 comment');
-        assert.equal(
-            element._computeCommentString(comments, 2, path, {status: 'U'}),
-            'no changes, 1 comment');
-        assert.equal(
-            element._computeCommentString(comments, 3, path, {status: 'A'}),
-            '2 comments, 2 unresolved');
-        assert.equal(
-            element._computeCommentString(
-                comments, 4, path, {status: 'M'}
-            ), '');
-        assert.equal(
-            element._computeCommentString(comments, 4, path, {status: 'U'}),
-            'no changes');
-        done();
-      });
-    });
-
-    suite('url params', () => {
-      setup(() => {
-        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', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '10',
-        };
-        element._change = {_number: 42};
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md',
-              '/COMMIT_MSG', '/MERGE_LIST']);
-        element._path = 'glados.txt';
-        const expectedFormattedFiles = [
-          {
-            text: 'chell.go',
-            mobileText: 'chell.go',
-            value: 'chell.go',
-            bottomText: '',
-          }, {
-            text: 'glados.txt',
-            mobileText: 'glados.txt',
-            value: 'glados.txt',
-            bottomText: '',
-          }, {
-            text: 'wheatley.md',
-            mobileText: 'wheatley.md',
-            value: 'wheatley.md',
-            bottomText: '',
-          },
-          {
-            text: 'Commit message',
-            mobileText: 'Commit message',
-            value: '/COMMIT_MSG',
-            bottomText: '',
-          },
-          {
-            text: 'Merge list',
-            mobileText: 'Merge list',
-            value: '/MERGE_LIST',
-            bottomText: '',
-          },
-        ];
-
-        assert.deepEqual(element._formattedFiles, expectedFormattedFiles);
-        assert.equal(element._formattedFiles[1].value, element._path);
-      });
-
-      test('prev/up/next links', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '10',
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 10, commit: {parents: []}},
-          },
-        };
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md']);
-        element._path = 'glados.txt';
-        flushAsynchronousOperations();
-        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');
-        assert.equal(linkEls[2].getAttribute('href'),
-            '42-wheatley.md-10-PARENT');
-        element._path = 'wheatley.md';
-        flushAsynchronousOperations();
-        assert.equal(linkEls[0].getAttribute('href'),
-            '42-glados.txt-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.isFalse(linkEls[2].hasAttribute('href'));
-        element._path = 'chell.go';
-        flushAsynchronousOperations();
-        assert.isFalse(linkEls[0].hasAttribute('href'));
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'),
-            '42-glados.txt-10-PARENT');
-        element._path = 'not_a_real_file';
-        flushAsynchronousOperations();
-        assert.equal(linkEls[0].getAttribute('href'),
-            '42-wheatley.md-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'), '42-chell.go-10-PARENT');
-      });
-
-      test('prev/up/next links with patch range', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: '5',
-          patchNum: '10',
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 5, commit: {parents: []}},
-            b: {_number: 10, commit: {parents: []}},
-          },
-        };
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md']);
-        element._path = 'glados.txt';
-        flushAsynchronousOperations();
-        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');
-        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
-        element._path = 'wheatley.md';
-        flushAsynchronousOperations();
-        assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.isFalse(linkEls[2].hasAttribute('href'));
-        element._path = 'chell.go';
-        flushAsynchronousOperations();
-        assert.isFalse(linkEls[0].hasAttribute('href'));
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
-      });
-    });
-
-    test('_handlePatchChange calls navigateToDiff correctly', () => {
-      const navigateStub = sandbox.stub(GerritNav, 'navigateToDiff');
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._path = 'path/to/file.txt';
-
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '3',
-      };
-
-      const detail = {
-        basePatchNum: 'PARENT',
-        patchNum: '1',
-      };
-
-      element.$.rangeSelect.dispatchEvent(
-          new CustomEvent('patch-range-change', {detail, bubbles: false}));
-
-      assert(navigateStub.lastCall.calledWithExactly(element._change,
-          element._path, '1', 'PARENT'));
-    });
-
-    test('_prefs.manual_review is respected', () => {
-      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
-          () => Promise.resolve());
-      const getReviewedStub = sandbox.stub(element, '_getReviewedStatus',
-          () => Promise.resolve());
-
-      sandbox.stub(element.$.diffHost, 'reload');
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
-        path: '/COMMIT_MSG',
-      };
-      element._prefs = {manual_review: true};
-      flushAsynchronousOperations();
-
-      assert.isFalse(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.called);
-
-      element._prefs = {};
-      flushAsynchronousOperations();
-
-      assert.isTrue(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.calledOnce);
-    });
-
-    test('file review status', () => {
-      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
-          () => Promise.resolve());
-      sandbox.stub(element.$.diffHost, 'reload');
-
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
-        path: '/COMMIT_MSG',
-      };
-      element._prefs = {};
-      flushAsynchronousOperations();
-
-      const commitMsg = dom(element.root).querySelector(
-          'input[type="checkbox"]');
-
-      assert.isTrue(commitMsg.checked);
-      MockInteractions.tap(commitMsg);
-      assert.isFalse(commitMsg.checked);
-      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(false));
-
-      MockInteractions.tap(commitMsg);
-      assert.isTrue(commitMsg.checked);
-      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
-      const callCount = saveReviewedStub.callCount;
-
-      element.set('params.view', GerritNav.View.CHANGE);
-      flushAsynchronousOperations();
-
-      // saveReviewedState observer observes params, but should not fire when
-      // view !== GerritNav.View.DIFF.
-      assert.equal(saveReviewedStub.callCount, callCount);
-    });
-
-    test('file review status with edit loaded', () => {
-      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState');
-
-      element._patchRange = {patchNum: element.EDIT_NAME};
-      flushAsynchronousOperations();
-
-      assert.isTrue(element._editMode);
-      element._setReviewed();
-      assert.isFalse(saveReviewedStub.called);
-    });
-
-    test('hash is determined from params', done => {
-      sandbox.stub(element.$.diffHost, 'reload');
-      sandbox.stub(element, '_initCursor');
-
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
-        path: '/COMMIT_MSG',
-        hash: 10,
-      };
-
-      flush(() => {
-        assert.isTrue(element._initCursor.calledOnce);
-        done();
-      });
-    });
-
-    test('diff mode selector correctly toggles the diff', () => {
-      const select = element.$.modeSelect;
-      const diffDisplay = element.$.diffHost;
-      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
-
-      // The mode selected in the view state reflects the selected option.
-      assert.equal(element._getDiffViewMode(), select.mode);
-
-      // The mode selected in the view state reflects the view rednered in the
-      // diff.
-      assert.equal(select.mode, diffDisplay.viewMode);
-
-      // We will simulate a user change of the selected mode.
-      const newMode = 'UNIFIED_DIFF';
-
-      // Set the mode, and simulate the change event.
-      element.set('changeViewState.diffMode', newMode);
-
-      // Make sure the handler was called and the state is still coherent.
-      assert.equal(element._getDiffViewMode(), newMode);
-      assert.equal(element._getDiffViewMode(), select.mode);
-      assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
-    });
-
-    test('diff mode selector initializes from preferences', () => {
-      let resolvePrefs;
-      const prefsPromise = new Promise(resolve => {
-        resolvePrefs = resolve;
-      });
-      sandbox.stub(element.$.restAPI, 'getPreferences', () => prefsPromise);
-
-      // Attach a new gr-diff-view so we can intercept the preferences fetch.
-      const view = document.createElement('gr-diff-view');
-      fixture('blank').appendChild(view);
-      flushAsynchronousOperations();
-
-      // At this point the diff mode doesn't yet have the user's preference.
-      assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      // Receive the overriding preference.
-      resolvePrefs({default_diff_view: 'UNIFIED'});
-      flushAsynchronousOperations();
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-    });
-
-    suite('_commitRange', () => {
-      setup(() => {
-        sandbox.stub(element.$.diffHost, 'reload');
-        sandbox.stub(element, '_initCursor');
-        sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve({
-          _number: 42,
-          revisions: {
-            'commit-sha-1': {
-              _number: 1,
-              commit: {
-                parents: [{commit: 'sha-1-parent'}],
-              },
-            },
-            'commit-sha-2': {_number: 2},
-            'commit-sha-3': {_number: 3},
-            'commit-sha-4': {_number: 4},
-            'commit-sha-5': {
-              _number: 5,
-              commit: {
-                parents: [{commit: 'sha-5-parent'}],
-              },
-            },
-          },
-        }));
-      });
-
-      test('uses the patchNum and basePatchNum ', done => {
-        element.params = {
-          view: GerritNav.View.DIFF,
-          changeNum: '42',
-          patchNum: '4',
-          basePatchNum: '2',
-          path: '/COMMIT_MSG',
-        };
-        flush(() => {
-          assert.deepEqual(element._commitRange, {
-            baseCommit: 'commit-sha-2',
-            commit: 'commit-sha-4',
-          });
-          done();
-        });
-      });
-
-      test('uses the parent when there is no base patch num ', done => {
-        element.params = {
-          view: GerritNav.View.DIFF,
-          changeNum: '42',
-          patchNum: '5',
-          path: '/COMMIT_MSG',
-        };
-        flush(() => {
-          assert.deepEqual(element._commitRange, {
-            commit: 'commit-sha-5',
-            baseCommit: 'sha-5-parent',
-          });
-          done();
-        });
-      });
-    });
-
-    test('_initCursor', () => {
-      assert.isNotOk(element.$.cursor.initialLineNumber);
-
-      // Does nothing when params specify no cursor address:
-      element._initCursor({});
-      assert.isNotOk(element.$.cursor.initialLineNumber);
-
-      // Does nothing when params specify side but no number:
-      element._initCursor({leftSide: true});
-      assert.isNotOk(element.$.cursor.initialLineNumber);
-
-      // Revision hash: specifies lineNum but not side.
-      element._initCursor({lineNum: 234});
-      assert.equal(element.$.cursor.initialLineNumber, 234);
-      assert.equal(element.$.cursor.side, 'right');
-
-      // Base hash: specifies lineNum and side.
-      element._initCursor({leftSide: true, lineNum: 345});
-      assert.equal(element.$.cursor.initialLineNumber, 345);
-      assert.equal(element.$.cursor.side, 'left');
-
-      // Specifies right side:
-      element._initCursor({leftSide: false, lineNum: 123});
-      assert.equal(element.$.cursor.initialLineNumber, 123);
-      assert.equal(element.$.cursor.side, 'right');
-    });
-
-    test('_getLineOfInterest', () => {
-      assert.isNull(element._getLineOfInterest({}));
-
-      let result = element._getLineOfInterest({lineNum: 12});
-      assert.equal(result.number, 12);
-      assert.isNotOk(result.leftSide);
-
-      result = element._getLineOfInterest({lineNum: 12, leftSide: true});
-      assert.equal(result.number, 12);
-      assert.isOk(result.leftSide);
-    });
-
-    test('_onLineSelected', () => {
-      const getUrlStub = sandbox.stub(GerritNav, 'getUrlForDiffById');
-      const replaceStateStub = sandbox.stub(history, 'replaceState');
-      sandbox.stub(element.$.cursor, 'getAddress')
-          .returns({number: 123, isLeftSide: false});
-
-      element._changeNum = 321;
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._patchRange = {
-        basePatchNum: '3',
-        patchNum: '5',
-      };
-      const e = {};
-      const detail = {number: 123, side: 'right'};
-
-      element._onLineSelected(e, detail);
-
-      assert.isTrue(replaceStateStub.called);
-      assert.isTrue(getUrlStub.called);
-    });
-
-    test('_onLineSelected w/o line address', () => {
-      const getUrlStub = sandbox.stub(GerritNav, 'getUrlForDiffById');
-      sandbox.stub(history, 'replaceState');
-      sandbox.stub(element.$.cursor, 'moveToLineNumber');
-      sandbox.stub(element.$.cursor, 'getAddress').returns(null);
-      element._changeNum = 321;
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._patchRange = {basePatchNum: '3', patchNum: '5'};
-      element._onLineSelected({}, {number: 123, side: 'right'});
-      assert.isTrue(getUrlStub.calledOnce);
-      assert.isUndefined(getUrlStub.lastCall.args[5]);
-      assert.isUndefined(getUrlStub.lastCall.args[6]);
-    });
-
-    test('_getDiffViewMode', () => {
-      // No user prefs or change view state set.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      // User prefs but no change view state set.
-      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
-      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
-
-      // User prefs and change view state set.
-      element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-    });
-
-    test('_handleToggleDiffMode', () => {
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      const e = {preventDefault: () => {}};
-      // Initial state.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      element._handleToggleDiffMode(e);
-      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
-
-      element._handleToggleDiffMode(e);
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-    });
-
-    suite('_loadComments', () => {
-      test('empty', done => {
-        element._loadComments().then(() => {
-          assert.equal(Object.keys(element._commentMap).length, 0);
-          done();
-        });
-      });
-
-      test('has paths', done => {
-        sandbox.stub(element, '_getPaths').returns({
-          'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
-          'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
-        });
-        sandbox.stub(element, '_getCommentsForPath').returns({meta: {}});
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: '3',
-          patchNum: '5',
-        };
-        element._loadComments().then(() => {
-          assert.deepEqual(Object.keys(element._commentMap),
-              ['path/to/file/one.cpp', 'path-to/file/two.py']);
-          done();
-        });
-      });
-    });
-
-    suite('_computeCommentSkips', () => {
-      test('empty file list', () => {
-        const commentMap = {
-          'path/one.jpg': true,
-          'path/three.wav': true,
-        };
-        const path = 'path/two.m4v';
-        const fileList = [];
-        const result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.isNull(result.previous);
-        assert.isNull(result.next);
-      });
-
-      test('finds skips', () => {
-        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
-        let path = fileList[1];
-        const commentMap = {};
-        commentMap[fileList[0]] = true;
-        commentMap[fileList[1]] = false;
-        commentMap[fileList[2]] = true;
-
-        let result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[0]);
-        assert.equal(result.next, fileList[2]);
-
-        commentMap[fileList[1]] = true;
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[0]);
-        assert.equal(result.next, fileList[2]);
-
-        path = fileList[0];
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.isNull(result.previous);
-        assert.equal(result.next, fileList[1]);
-
-        path = fileList[2];
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[1]);
-        assert.isNull(result.next);
-      });
-
-      suite('skip next/previous', () => {
-        let navToChangeStub;
-        let navToDiffStub;
-
-        setup(() => {
-          navToChangeStub = sandbox.stub(element, '_navToChangeView');
-          navToDiffStub = sandbox.stub(GerritNav, 'navigateToDiff');
-          element._files = getFilesFromFileList([
-            'path/one.jpg', 'path/two.m4v', 'path/three.wav',
-          ]);
-          element._patchRange = {patchNum: '2', basePatchNum: '1'};
-        });
-
-        suite('_moveToPreviousFileWithComment', () => {
-          test('no skips', () => {
-            element._moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('no previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = false;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToPreviousFileWithComment();
-            assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('w/ previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isTrue(navToDiffStub.calledOnce);
-          });
-        });
-
-        suite('_moveToNextFileWithComment', () => {
-          test('no skips', () => {
-            element._moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('no previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = false;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToNextFileWithComment();
-            assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('w/ previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isTrue(navToDiffStub.calledOnce);
-          });
-        });
-      });
-    });
-
-    test('_computeEditMode', () => {
-      const callCompute = range => element._computeEditMode({base: range});
-      assert.isFalse(callCompute({}));
-      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
-      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
-      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
-    });
-
-    test('_computeFileNum', () => {
-      assert.equal(element._computeFileNum('/foo',
-          [{value: '/foo'}, {value: '/bar'}]), 1);
-      assert.equal(element._computeFileNum('/bar',
-          [{value: '/foo'}, {value: '/bar'}]), 2);
-    });
-
-    test('_computeFileNumClass', () => {
-      assert.equal(element._computeFileNumClass(0, []), '');
-      assert.equal(element._computeFileNumClass(1,
-          [{value: '/foo'}, {value: '/bar'}]), 'show');
-    });
-
-    test('_getReviewedStatus', () => {
-      const promises = [];
-      element.$.restAPI.getReviewedFiles.restore();
-
-      sandbox.stub(element.$.restAPI, 'getReviewedFiles')
-          .returns(Promise.resolve(['path']));
-
-      promises.push(element._getReviewedStatus(true, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'path')
-          .then(reviewed => assert.isTrue(reviewed)));
-
-      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;
-      });
-
-      const isVisible = el => {
-        assert.ok(el);
-        return getComputedStyle(el).getPropertyValue('display') !== 'none';
-      };
-
-      test('reviewed checkbox', () => {
-        sandbox.stub(element, '_handlePatchChange');
-        element._patchRange = {patchNum: '1'};
-        // Reviewed checkbox should be shown.
-        assert.isTrue(isVisible(element.$.reviewed));
-        element.set('_patchRange.patchNum', element.EDIT_NAME);
-        flushAsynchronousOperations();
-
-        assert.isFalse(isVisible(element.$.reviewed));
-      });
-    });
-
-    test('_paramsChanged sets in projectLookup', () => {
-      sandbox.stub(element, '_getLineOfInterest');
-      sandbox.stub(element, '_initCursor');
-      const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-      element._paramsChanged({
-        view: GerritNav.View.DIFF,
-        changeNum: 101,
-        project: 'test-project',
-        path: '',
-      });
-      assert.isTrue(setStub.calledOnce);
-      assert.isTrue(setStub.calledWith(101, 'test-project'));
-    });
-
-    test('shift+m navigates to next unreviewed file', () => {
-      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      element._reviewedFiles = new Set(['file1', 'file2']);
-      element._path = 'file1';
-      const reviewedStub = sandbox.stub(element, '_setReviewed');
-      const navStub = sandbox.stub(element, '_navToFile');
-      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
-      flushAsynchronousOperations();
-
-      assert.isTrue(reviewedStub.lastCall.args[0]);
-      assert.deepEqual(navStub.lastCall.args, [
-        'file1',
-        ['file1', 'file3'],
-        1,
-      ]);
-    });
-
-    test('File change should trigger navigateToDiff once', () => {
-      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      sandbox.stub(element, '_getLineOfInterest');
-      sandbox.stub(element, '_initCursor');
-      sandbox.stub(GerritNav, 'navigateToDiff');
-
-      // Load file1
-      element._paramsChanged({
-        view: GerritNav.View.DIFF,
-        patchNum: 1,
-        changeNum: 101,
-        project: 'test-project',
-        path: 'file1',
-      });
-      assert.isTrue(GerritNav.navigateToDiff.notCalled);
-
-      // Switch to file2
-      element.$.dropdown.value = 'file2';
-      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
-
-      // This is to mock the param change triggered by above navigate
-      element._paramsChanged({
-        view: GerritNav.View.DIFF,
-        patchNum: 1,
-        changeNum: 101,
-        project: 'test-project',
-        path: 'file2',
-      });
-
-      // No extra call
-      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
-    });
-
-    test('_computeDownloadDropdownLinks', () => {
-      const downloadLinks = [
-        {
-          url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
-          name: 'Patch',
-        },
-        {
-          url: '/changes/test~12/revisions/1' +
-              '/files/index.php/download?parent=1',
-          name: 'Left Content',
-        },
-        {
-          url: '/changes/test~12/revisions/1' +
-              '/files/index.php/download',
-          name: 'Right Content',
-        },
-      ];
-
-      const side = {
-        meta_a: true,
-        meta_b: true,
-      };
-
-      const base = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      assert.deepEqual(
-          element._computeDownloadDropdownLinks(
-              'test', 12, base, 'index.php', side),
-          downloadLinks);
-    });
-
-    test('_computeDownloadDropdownLinks diff returns renamed', () => {
-      const downloadLinks = [
-        {
-          url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
-          name: 'Patch',
-        },
-        {
-          url: '/changes/test~12/revisions/2' +
-              '/files/index2.php/download',
-          name: 'Left Content',
-        },
-        {
-          url: '/changes/test~12/revisions/3' +
-              '/files/index.php/download',
-          name: 'Right Content',
-        },
-      ];
-
-      const side = {
-        change_type: 'RENAMED',
-        meta_a: {
-          name: 'index2.php',
-        },
-        meta_b: true,
-      };
-
-      const base = {
-        patchNum: 3,
-        basePatchNum: 2,
-      };
-
-      assert.deepEqual(
-          element._computeDownloadDropdownLinks(
-              'test', 12, base, 'index.php', side),
-          downloadLinks);
-    });
-
-    test('_computeDownloadFileLink', () => {
-      const base = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      assert.equal(
-          element._computeDownloadFileLink(
-              'test', 12, base, 'index.php', true),
-          '/changes/test~12/revisions/1/files/index.php/download?parent=1');
-
-      assert.equal(
-          element._computeDownloadFileLink(
-              'test', 12, base, 'index.php', false),
-          '/changes/test~12/revisions/1/files/index.php/download');
-    });
-
-    test('_computeDownloadPatchLink', () => {
-      assert.equal(
-          element._computeDownloadPatchLink(
-              'test', 12, {patchNum: 1}, 'index.php'),
-          '/changes/test~12/revisions/1/patch?zip&path=index.php');
-    });
-  });
-
-  suite('gr-diff-view tests unmodified files with comments', () => {
-    let sandbox;
-    let element;
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      const changedFiles = {
-        'file1.txt': {},
-        'a/b/test.c': {},
-      };
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({change: {}}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getProjectConfig() { return Promise.resolve({}); },
-        getDiffChangeDetail() { return Promise.resolve({}); },
-        getChangeFiles() { return Promise.resolve(changedFiles); },
-        saveFileReviewed() { return Promise.resolve(); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        getReviewedFiles() { return Promise.resolve([]); },
-      });
-      element = fixture('basic');
-      return element._loadComments();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_getFiles add files with comments without changes', () => {
-      const patchChangeRecord = {
-        base: {
-          basePatchNum: '5',
-          patchNum: '10',
-        },
-      };
-      const changeComments = {
-        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': {},
-              },
-            });
-          });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
new file mode 100644
index 0000000..9a08723
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -0,0 +1,1914 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-view.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {ChangeStatus} from '../../../constants/constants.js';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
+import {appContext} from '../../../services/app-context.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {
+  createChange,
+  createRevisions,
+} from '../../../test/test-data-generators.js';
+
+const basicFixture = fixtureFromElement('gr-diff-view');
+
+const blankFixture = fixtureFromElement('div');
+
+suite('gr-diff-view tests', () => {
+  suite('basic tests', () => {
+    let element;
+    let clock;
+
+    suiteSetup(() => {
+      const kb = TestKeyboardShortcutBinder.push();
+      kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
+      kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
+      kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
+      kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
+      kb.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+      kb.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+      kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
+      kb.bindShortcut(Shortcut.SAVE_COMMENT, 'ctrl+s');
+      kb.bindShortcut(Shortcut.NEXT_FILE, ']');
+      kb.bindShortcut(Shortcut.PREV_FILE, '[');
+      kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
+      kb.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+      kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
+      kb.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+      kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
+      kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+      kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+      kb.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
+      kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
+      kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
+      kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+      kb.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+      kb.bindShortcut(Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
+      kb.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
+      kb.bindShortcut(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
+      kb.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+      kb.bindShortcut(Shortcut.TOGGLE_BLAME, 'b');
+      kb.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
+    });
+
+    suiteTeardown(() => {
+      TestKeyboardShortcutBinder.pop();
+    });
+
+    const PARENT = 'PARENT';
+
+    function getFilesFromFileList(fileList) {
+      const changeFilesByPath = fileList.reduce((files, path) => {
+        files[path] = {};
+        return files;
+      }, {});
+      return {
+        sortedFileList: fileList,
+        changeFilesByPath,
+      };
+    }
+
+    setup(async () => {
+      clock = sinon.useFakeTimers();
+      sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
+      stub('gr-rest-api-interface', {
+        getConfig() {
+          return Promise.resolve({change: {}});
+        },
+        getLoggedIn() {
+          return Promise.resolve(false);
+        },
+        getProjectConfig() {
+          return Promise.resolve({});
+        },
+        getDiffChangeDetail() {
+          return Promise.resolve({});
+        },
+        getChangeFiles() {
+          return Promise.resolve({});
+        },
+        saveFileReviewed() {
+          return Promise.resolve();
+        },
+        getDiffComments() {
+          return Promise.resolve({});
+        },
+        getDiffRobotComments() {
+          return Promise.resolve({});
+        },
+        getDiffDrafts() {
+          return Promise.resolve({});
+        },
+        getReviewedFiles() {
+          return Promise.resolve([]);
+        },
+      });
+      element = basicFixture.instantiate();
+      element._changeNum = '42';
+      element._path = 'some/path.txt';
+      element._change = {};
+      element._diff = {content: []};
+      element._patchRange = {
+        patchNum: 77,
+        basePatchNum: 'PARENT',
+      };
+      sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
+        _comments: {'/COMMIT_MSG': [
+          {
+            id: 'c1',
+            line: 10,
+            patch_set: 2,
+            __commentSide: 'left',
+            path: '/COMMIT_MSG',
+          }, {
+            id: 'c3',
+            line: 10,
+            patch_set: 'PARENT',
+            __commentSide: 'left',
+            path: '/COMMIT_MSG',
+          },
+        ]},
+        computeCommentThreadCount: () => {},
+        computeUnresolvedNum: () => {},
+        getPaths: () => {},
+        getCommentsBySideForPath: () => {},
+        findCommentById: _testOnly_findCommentById,
+      }));
+      await element._loadComments();
+      await flush();
+    });
+
+    teardown(() => {
+      clock.restore();
+      sinon.restore();
+    });
+
+    test('params change triggers diffViewDisplayed()', () => {
+      sinon.stub(element.reporting, 'diffViewDisplayed');
+      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+      sinon.stub(element, '_initPatchRange');
+      sinon.stub(element, '_getFiles');
+      sinon.spy(element, '_paramsChanged');
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: 2,
+        basePatchNum: 1,
+        path: '/COMMIT_MSG',
+      };
+      return element._paramsChanged.returnValues[0].then(() => {
+        assert.isTrue(element.reporting.diffViewDisplayed.calledOnce);
+      });
+    });
+
+    test('comment route', () => {
+      const initLineOfInterestAndCursorStub =
+        sinon.stub(element, '_initLineOfInterestAndCursor');
+      sinon.stub(element, '_getFiles');
+      sinon.stub(element.reporting, 'diffViewDisplayed');
+      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+      sinon.spy(element, '_paramsChanged');
+      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+        ...createChange(),
+        revisions: createRevisions(11),
+      }));
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        commentLink: true,
+        commentId: 'c1',
+      };
+      sinon.stub(element.$.diffHost, '_commentsChanged');
+      sinon.stub(element, '_getCommentsForPath').returns({
+        left: [{id: 'c1', __commentSide: 'left', line: 10}],
+        right: [{id: 'c2', __commentSide: 'right', line: 11}],
+      });
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(11),
+      };
+      return element._paramsChanged.returnValues[0].then(() => {
+        assert.isTrue(initLineOfInterestAndCursorStub.
+            calledWithExactly(true));
+        assert.equal(element._focusLineNum, 10);
+        assert.equal(element._patchRange.patchNum, 11);
+        assert.equal(element._patchRange.basePatchNum, 2);
+      });
+    });
+
+    test('params change causes blame to load if it was set to true', () => {
+      // Blame loads for subsequent files if it was loaded for one file
+      element._isBlameLoaded = true;
+      sinon.stub(element.reporting, 'diffViewDisplayed');
+      sinon.stub(element, '_loadBlame');
+      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+      sinon.spy(element, '_paramsChanged');
+      sinon.stub(element, '_initPatchRange');
+      sinon.stub(element, '_getFiles');
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: 2,
+        basePatchNum: 1,
+        path: '/COMMIT_MSG',
+      };
+      return element._paramsChanged.returnValues[0].then(() => {
+        assert.isTrue(element._isBlameLoaded);
+        assert.isTrue(element._loadBlame.calledOnce);
+      });
+    });
+
+    test('unchanged diff X vs latest from comment links navigates to base vs X'
+        , () => {
+          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          sinon.stub(element.reporting, 'diffViewDisplayed');
+          sinon.stub(element, '_loadBlame');
+          sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+          sinon.stub(element, '_isFileUnchanged').returns(true);
+          sinon.spy(element, '_paramsChanged');
+          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+            ...createChange(),
+            revisions: createRevisions(11),
+          }));
+          element.params = {
+            view: GerritNav.View.DIFF,
+            changeNum: '42',
+            path: '/COMMIT_MSG',
+            commentLink: true,
+            commentId: 'c1',
+          };
+          sinon.stub(element.$.diffHost, '_commentsChanged');
+          element._change = {
+            ...createChange(),
+            revisions: createRevisions(11),
+          };
+          return element._paramsChanged.returnValues[0].then(() => {
+            assert.isTrue(diffNavStub.lastCall.calledWithExactly(
+                element._change, '/COMMIT_MSG', 2, 'PARENT', 10));
+          });
+        });
+
+    test('unchanged diff Base vs latest from comment does not navigate'
+        , () => {
+          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          sinon.stub(element.reporting, 'diffViewDisplayed');
+          sinon.stub(element, '_loadBlame');
+          sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+          sinon.stub(element, '_isFileUnchanged').returns(true);
+          sinon.spy(element, '_paramsChanged');
+          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+            ...createChange(),
+            revisions: createRevisions(11),
+          }));
+          element.params = {
+            view: GerritNav.View.DIFF,
+            changeNum: '42',
+            path: '/COMMIT_MSG',
+            commentLink: true,
+            commentId: 'c3',
+          };
+          sinon.stub(element.$.diffHost, '_commentsChanged');
+          element._change = {
+            ...createChange(),
+            revisions: createRevisions(11),
+          };
+          return element._paramsChanged.returnValues[0].then(() => {
+            assert.isFalse(diffNavStub.called);
+          });
+        });
+
+    test('_isFileUnchanged', () => {
+      let diff = {
+        content: [
+          {a: 'abcd', ab: 'ef'},
+          {b: 'ancd', a: 'xx'},
+        ],
+      };
+      assert.equal(element._isFileUnchanged(diff), false);
+      diff = {
+        content: [
+          {ab: 'abcd'},
+          {ab: 'ancd'},
+        ],
+      };
+      assert.equal(element._isFileUnchanged(diff), true);
+      diff = {
+        content: [
+          {a: 'abcd', ab: 'ef', common: true},
+          {b: 'ancd', ab: 'xx'},
+        ],
+      };
+      assert.equal(element._isFileUnchanged(diff), false);
+      diff = {
+        content: [
+          {a: 'abcd', ab: 'ef', common: true},
+          {b: 'ancd', ab: 'xx', common: true},
+        ],
+      };
+      assert.equal(element._isFileUnchanged(diff), true);
+    });
+
+    test('diff toast to go to latest is shown and not base', async () => {
+      sinon.stub(element.reporting, 'diffViewDisplayed');
+      sinon.stub(element, '_loadBlame');
+      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+      sinon.spy(element, '_paramsChanged');
+      element.$.restAPI.getDiffChangeDetail.restore();
+      sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
+          .returns(
+              Promise.resolve({
+                ...createChange(),
+                revisions: createRevisions(11),
+              }));
+      element._patchRange = {
+        patchNum: 2,
+        basePatchNum: 1,
+      };
+      sinon.stub(element, '_isFileUnchanged').returns(false);
+      const toastStub =
+          sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        project: 'p',
+        commentId: 'c1',
+        commentLink: true,
+      };
+      await element._paramsChanged.returnValues[0];
+      assert.isTrue(toastStub.called);
+    });
+
+    test('toggle left diff with a hotkey', () => {
+      const toggleLeftDiffStub = sinon.stub(
+          element.$.diffHost, 'toggleLeftDiff');
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      assert.isTrue(toggleLeftDiffStub.calledOnce);
+    });
+
+    test('keyboard shortcuts', () => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 10,
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+      element.changeViewState.selectedFileIndex = 1;
+      element._loggedIn = true;
+
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert(changeNavStub.lastCall.calledWith(element._change),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
+          10, PARENT), 'Should navigate to /c/42/10/wheatley.md');
+      element._path = 'wheatley.md';
+      assert.equal(element.changeViewState.selectedFileIndex, 2);
+      assert.isTrue(element._loading);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
+          10, PARENT), 'Should navigate to /c/42/10/glados.txt');
+      element._path = 'glados.txt';
+      assert.equal(element.changeViewState.selectedFileIndex, 1);
+      assert.isTrue(element._loading);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', 10,
+          PARENT), 'Should navigate to /c/42/10/chell.go');
+      element._path = 'chell.go';
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+      assert.isTrue(element._loading);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(changeNavStub.lastCall.calledWith(element._change),
+          'Should navigate to /c/42/');
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+      assert.isTrue(element._loading);
+
+      const showPrefsStub =
+          sinon.stub(element.$.diffPreferencesDialog, 'open').callsFake(
+              () => Promise.resolve());
+
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert(showPrefsStub.calledOnce);
+
+      element.disableDiffPrefs = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert(showPrefsStub.calledOnce);
+
+      let scrollStub = sinon.stub(element.$.cursor, 'moveToNextChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.$.cursor, 'moveToNextCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.$.cursor,
+          'moveToPreviousCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
+      assert(scrollStub.calledOnce);
+
+      const computeContainerClassStub = sinon.stub(element.$.diffHost.$.diff,
+          '_computeContainerClass');
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert(computeContainerClassStub.lastCall.calledWithExactly(
+          false, 'SIDE_BY_SIDE', true));
+
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+      assert(computeContainerClassStub.lastCall.calledWithExactly(
+          false, 'SIDE_BY_SIDE', false));
+
+      sinon.stub(element, '_setReviewed');
+      sinon.spy(element, '_handleToggleFileReviewed');
+      element.$.reviewed.checked = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      assert.isFalse(element._setReviewed.called);
+      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+
+      clock.tick(1000);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      assert.isTrue(element._handleToggleFileReviewed.calledTwice);
+      assert.isTrue(element._setReviewed.called);
+      assert.equal(element._setReviewed.lastCall.args[0], true);
+    });
+
+    test('shift+x shortcut expands all diff context', () => {
+      const expandStub = sinon.stub(element.$.diffHost, 'expandAllContext');
+      MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
+      flush();
+      assert.isTrue(expandStub.called);
+    });
+
+    test('diff against base', () => {
+      element._patchRange = {
+        basePatchNum: 5,
+        patchNum: 10,
+      };
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffAgainstBase(new CustomEvent(''));
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 10);
+      assert.isNotOk(args[3]);
+    });
+
+    test('diff against latest', () => {
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(12),
+      };
+      element._patchRange = {
+        basePatchNum: 5,
+        patchNum: 10,
+      };
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffAgainstLatest(new CustomEvent(''));
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 12);
+      assert.equal(args[3], 5);
+    });
+
+    test('_handleDiffBaseAgainstLeft', () => {
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(10),
+      };
+      element._patchRange = {
+        patchNum: 3,
+        basePatchNum: 1,
+      };
+      element.params = {};
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffBaseAgainstLeft(new CustomEvent(''));
+      assert(diffNavStub.called);
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 1);
+      assert.equal(args[3], 'PARENT');
+      assert.isNotOk(args[4]);
+    });
+
+    test('_handleDiffBaseAgainstLeft when initially navigating to a comment',
+        () => {
+          element._change = {
+            ...createChange(),
+            revisions: createRevisions(10),
+          };
+          element._patchRange = {
+            patchNum: 3,
+            basePatchNum: 1,
+          };
+          sinon.stub(element, '_paramsChanged');
+          element.params = {commentLink: true, view: GerritView.DIFF};
+          element._focusLineNum = 10;
+          sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          element._handleDiffBaseAgainstLeft(new CustomEvent(''));
+          assert(diffNavStub.called);
+          const args = diffNavStub.getCall(0).args;
+          assert.equal(args[2], 1);
+          assert.equal(args[3], 'PARENT');
+          assert.equal(args[4], 10);
+        });
+
+    test('_handleDiffRightAgainstLatest', () => {
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(10),
+      };
+      element._patchRange = {
+        basePatchNum: 1,
+        patchNum: 3,
+      };
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffRightAgainstLatest(new CustomEvent(''));
+      assert(diffNavStub.called);
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 10);
+      assert.equal(args[3], 3);
+    });
+
+    test('_handleDiffBaseAgainstLatest', () => {
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(10),
+      };
+      element._patchRange = {
+        basePatchNum: 1,
+        patchNum: 3,
+      };
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffBaseAgainstLatest(new CustomEvent(''));
+      assert(diffNavStub.called);
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 10);
+      assert.isNotOk(args[3]);
+    });
+
+    test('keyboard shortcuts with patch range', () => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 5,
+        patchNum: 10,
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+          b: {_number: 5, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+          'should only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+          5), 'Should navigate to /c/42/5..10');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+          5), 'Should navigate to /c/42/5..10');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'wheatley.md', 10, 5),
+      'Should navigate to /c/42/5..10/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'glados.txt', 10, 5),
+      'Should navigate to /c/42/5..10/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(
+          element._change,
+          'chell.go',
+          10,
+          5),
+      'Should navigate to /c/42/5..10/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert.isTrue(element._loading);
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+          5),
+      'Should navigate to /c/42/5..10');
+
+      assert.isUndefined(element.changeViewState.showDownloadDialog);
+      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+      assert.isTrue(element.changeViewState.showDownloadDialog);
+    });
+
+    test('keyboard shortcuts with old patch number', () => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1,
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+          'should only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
+          PARENT), 'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
+          PARENT), 'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'wheatley.md', 1, PARENT),
+      'Should navigate to /c/42/1/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'glados.txt', 1, PARENT),
+      'Should navigate to /c/42/1/glados.txt');
+      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');
+      element._path = 'chell.go';
+
+      changeNavStub.reset();
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
+          PARENT), 'Should navigate to /c/42/1');
+      assert.isTrue(changeNavStub.calledOnce);
+    });
+
+    test('edit should redirect to edit page', done => {
+      element._loggedIn = true;
+      element._path = 't.txt';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1,
+      };
+      element._change = {
+        _number: 42,
+        project: 'gerrit',
+        status: ChangeStatus.NEW,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      flush(() => {
+        const editBtn = element.shadowRoot
+            .querySelector('.editButton gr-button');
+        assert.isTrue(!!editBtn);
+        MockInteractions.tap(editBtn);
+        assert.isTrue(redirectStub.called);
+        assert.isTrue(redirectStub.lastCall.calledWithExactly(
+            GerritNav.getEditUrlForDiff(
+                element._change,
+                element._path,
+                element._patchRange.patchNum
+            )));
+        done();
+      });
+    });
+
+    test('edit should redirect to edit page with line number', done => {
+      const lineNumber = 42;
+      element._loggedIn = true;
+      element._path = 't.txt';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1,
+      };
+      element._change = {
+        _number: 42,
+        project: 'gerrit',
+        status: ChangeStatus.NEW,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      sinon.stub(element.$.cursor, 'getAddress')
+          .returns({number: lineNumber, isLeftSide: false});
+      const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      flush(() => {
+        const editBtn = element.shadowRoot
+            .querySelector('.editButton gr-button');
+        assert.isTrue(!!editBtn);
+        MockInteractions.tap(editBtn);
+        assert.isTrue(redirectStub.called);
+        assert.isTrue(redirectStub.lastCall.calledWithExactly(
+            GerritNav.getEditUrlForDiff(
+                element._change,
+                element._path,
+                element._patchRange.patchNum,
+                lineNumber
+            )));
+        done();
+      });
+    });
+
+    function isEditVisibile({loggedIn, changeStatus}) {
+      return new Promise(resolve => {
+        element._loggedIn = loggedIn;
+        element._path = 't.txt';
+        element._patchRange = {
+          basePatchNum: PARENT,
+          patchNum: 1,
+        };
+        element._change = {
+          _number: 42,
+          status: changeStatus,
+          revisions: {
+            a: {_number: 1, commit: {parents: []}},
+            b: {_number: 2, commit: {parents: []}},
+          },
+        };
+        flush(() => {
+          const editBtn = element.shadowRoot
+              .querySelector('.editButton gr-button');
+          resolve(!!editBtn);
+        });
+      });
+    }
+
+    test('edit visible only when logged and status NEW', async () => {
+      for (const changeStatus in ChangeStatus) {
+        if (!ChangeStatus.hasOwnProperty(changeStatus)) {
+          continue;
+        }
+        assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
+            `loggedIn: false, changeStatus: ${changeStatus}`);
+
+        if (changeStatus !== ChangeStatus.NEW) {
+          assert.isFalse(await isEditVisibile({loggedIn: true, changeStatus}),
+              `loggedIn: true, changeStatus: ${changeStatus}`);
+        } else {
+          assert.isTrue(await isEditVisibile({loggedIn: true, changeStatus}),
+              `loggedIn: true, changeStatus: ${changeStatus}`);
+        }
+      }
+    });
+
+    test('edit visible when logged and status NEW', async () => {
+      assert.isTrue(await isEditVisibile(
+          {loggedIn: true, changeStatus: ChangeStatus.NEW}));
+    });
+
+    test('edit hidden when logged and status ABANDONED', async () => {
+      assert.isFalse(await isEditVisibile(
+          {loggedIn: true, changeStatus: ChangeStatus.ABANDONED}));
+    });
+
+    test('edit hidden when logged and status MERGED', async () => {
+      assert.isFalse(await isEditVisibile(
+          {loggedIn: true, changeStatus: ChangeStatus.MERGED}));
+    });
+
+    suite('diff prefs hidden', () => {
+      test('when no prefs or logged out', () => {
+        element.disableDiffPrefs = false;
+        element._loggedIn = false;
+        flush();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+        element._loggedIn = true;
+        flush();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+        element._loggedIn = false;
+        element._prefs = {font_size: '12'};
+        flush();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+        element._loggedIn = true;
+        flush();
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+      });
+
+      test('when disableDiffPrefs is set', () => {
+        element._loggedIn = true;
+        element._prefs = {font_size: '12'};
+        element.disableDiffPrefs = false;
+        flush();
+
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+        element.disableDiffPrefs = true;
+        flush();
+
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+      });
+    });
+
+    test('prefsButton opens gr-diff-preferences', () => {
+      const handlePrefsTapSpy = sinon.spy(element, '_handlePrefsTap');
+      const overlayOpenStub = sinon.stub(element.$.diffPreferencesDialog,
+          'open');
+      const prefsButton =
+          element.root.querySelector('.prefsButton');
+
+      MockInteractions.tap(prefsButton);
+
+      assert.isTrue(handlePrefsTapSpy.called);
+      assert.isTrue(overlayOpenStub.called);
+    });
+
+    test('_computeCommentString', done => {
+      const path = '/test';
+      element.$.commentAPI.loadAll().then(comments => {
+        const commentThreadCountStub =
+            sinon.stub(comments, 'computeCommentThreadCount');
+        const unresolvedCountStub =
+            sinon.stub(comments, 'computeUnresolvedNum');
+        commentThreadCountStub.withArgs({patchNum: 1, path}).returns(0);
+        commentThreadCountStub.withArgs({patchNum: 2, path}).returns(1);
+        commentThreadCountStub.withArgs({patchNum: 3, path}).returns(2);
+        commentThreadCountStub.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');
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'M'}),
+            '1 comment');
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'U'}),
+            'no changes, 1 comment');
+        assert.equal(
+            element._computeCommentString(comments, 3, path, {status: 'A'}),
+            '2 comments, 2 unresolved');
+        assert.equal(
+            element._computeCommentString(
+                comments, 4, path, {status: 'M'}
+            ), '');
+        assert.equal(
+            element._computeCommentString(comments, 4, path, {status: 'U'}),
+            'no changes');
+        done();
+      });
+    });
+
+    suite('url params', () => {
+      setup(() => {
+        sinon.stub(element, '_getFiles');
+        sinon.stub(
+            GerritNav,
+            'getUrlForDiff')
+            .callsFake((c, p, pn, bpn) => `${c._number}-${p}-${pn}-${bpn}`);
+        sinon.stub(
+            GerritNav
+            , 'getUrlForChange')
+            .callsFake((c, pn, bpn) => `${c._number}-${pn}-${bpn}`);
+      });
+
+      test('_formattedFiles', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: PARENT,
+          patchNum: 10,
+        };
+        // computeCommentThreadCount is an empty function hence stubbing
+        // function that depends on it's return value
+        sinon.stub(element, '_computeCommentString').returns('');
+        element._change = {_number: 42};
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md',
+              '/COMMIT_MSG', '/MERGE_LIST']);
+        element._path = 'glados.txt';
+        const expectedFormattedFiles = [
+          {
+            text: 'chell.go',
+            mobileText: 'chell.go',
+            value: 'chell.go',
+            bottomText: '',
+          }, {
+            text: 'glados.txt',
+            mobileText: 'glados.txt',
+            value: 'glados.txt',
+            bottomText: '',
+          }, {
+            text: 'wheatley.md',
+            mobileText: 'wheatley.md',
+            value: 'wheatley.md',
+            bottomText: '',
+          },
+          {
+            text: 'Commit message',
+            mobileText: 'Commit message',
+            value: '/COMMIT_MSG',
+            bottomText: '',
+          },
+          {
+            text: 'Merge list',
+            mobileText: 'Merge list',
+            value: '/MERGE_LIST',
+            bottomText: '',
+          },
+        ];
+
+        assert.deepEqual(element._formattedFiles, expectedFormattedFiles);
+        assert.equal(element._formattedFiles[1].value, element._path);
+      });
+
+      test('prev/up/next links', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: PARENT,
+          patchNum: 10,
+        };
+        element._change = {
+          _number: 42,
+          revisions: {
+            a: {_number: 10, commit: {parents: []}},
+          },
+        };
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md']);
+        element._path = 'glados.txt';
+        flush();
+        const linkEls = 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');
+        assert.equal(linkEls[2].getAttribute('href'),
+            '42-wheatley.md-10-PARENT');
+        element._path = 'wheatley.md';
+        flush();
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-glados.txt-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.isFalse(linkEls[2].hasAttribute('href'));
+        element._path = 'chell.go';
+        flush();
+        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'),
+            '42-glados.txt-10-PARENT');
+        element._path = 'not_a_real_file';
+        flush();
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-wheatley.md-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'), '42-chell.go-10-PARENT');
+      });
+
+      test('prev/up/next links with patch range', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: 5,
+          patchNum: 10,
+        };
+        element._change = {
+          _number: 42,
+          revisions: {
+            a: {_number: 5, commit: {parents: []}},
+            b: {_number: 10, commit: {parents: []}},
+          },
+        };
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md']);
+        element._path = 'glados.txt';
+        flush();
+        const linkEls = 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');
+        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
+        element._path = 'wheatley.md';
+        flush();
+        assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.isFalse(linkEls[2].hasAttribute('href'));
+        element._path = 'chell.go';
+        flush();
+        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
+      });
+    });
+
+    test('_handlePatchChange calls navigateToDiff correctly', () => {
+      const navigateStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._path = 'path/to/file.txt';
+
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 3,
+      };
+
+      const detail = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+
+      element.$.rangeSelect.dispatchEvent(
+          new CustomEvent('patch-range-change', {detail, bubbles: false}));
+
+      assert(navigateStub.lastCall.calledWithExactly(element._change,
+          element._path, 1, 'PARENT'));
+    });
+
+    test('_prefs.manual_review is respected', () => {
+      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
+          .callsFake(() => Promise.resolve());
+      const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
+          .callsFake(() => Promise.resolve());
+
+      sinon.stub(element.$.diffHost, 'reload');
+      element._loggedIn = true;
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: 2,
+        basePatchNum: 1,
+        path: '/COMMIT_MSG',
+      };
+      element._patchRange = {
+        patchNum: 2,
+        basePatchNum: 1,
+      };
+      element._prefs = {manual_review: true};
+      flush();
+
+      assert.isFalse(saveReviewedStub.called);
+      assert.isTrue(getReviewedStub.called);
+
+      element._prefs = {};
+      flush();
+
+      assert.isTrue(saveReviewedStub.called);
+      assert.isTrue(getReviewedStub.calledOnce);
+    });
+
+    test('file review status', () => {
+      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
+          .callsFake(() => Promise.resolve());
+      sinon.stub(element.$.diffHost, 'reload');
+
+      element._loggedIn = true;
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: 2,
+        basePatchNum: 1,
+        path: '/COMMIT_MSG',
+      };
+      element._patchRange = {
+        patchNum: 2,
+        basePatchNum: 1,
+      };
+      element._prefs = {};
+      flush();
+
+      const commitMsg = element.root.querySelector(
+          'input[type="checkbox"]');
+
+      assert.isTrue(commitMsg.checked);
+      MockInteractions.tap(commitMsg);
+      assert.isFalse(commitMsg.checked);
+      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(false));
+
+      MockInteractions.tap(commitMsg);
+      assert.isTrue(commitMsg.checked);
+      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
+      const callCount = saveReviewedStub.callCount;
+
+      element.set('params.view', GerritNav.View.CHANGE);
+      flush();
+
+      // saveReviewedState observer observes params, but should not fire when
+      // view !== GerritNav.View.DIFF.
+      assert.equal(saveReviewedStub.callCount, callCount);
+    });
+
+    test('file review status with edit loaded', () => {
+      const saveReviewedStub = sinon.stub(element, '_saveReviewedState');
+
+      element._patchRange = {patchNum: SPECIAL_PATCH_SET_NUM.EDIT};
+      flush();
+
+      assert.isTrue(element._editMode);
+      element._setReviewed();
+      assert.isFalse(saveReviewedStub.called);
+    });
+
+    test('hash is determined from params', done => {
+      sinon.stub(element.$.diffHost, 'reload');
+      sinon.stub(element, '_initLineOfInterestAndCursor');
+
+      element._loggedIn = true;
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: 2,
+        basePatchNum: 1,
+        path: '/COMMIT_MSG',
+        hash: 10,
+      };
+
+      flush(() => {
+        assert.isTrue(element._initLineOfInterestAndCursor.calledOnce);
+        done();
+      });
+    });
+
+    test('diff mode selector correctly toggles the diff', () => {
+      const select = element.$.modeSelect;
+      const diffDisplay = element.$.diffHost;
+      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+
+      // The mode selected in the view state reflects the selected option.
+      assert.equal(element._getDiffViewMode(), select.mode);
+
+      // The mode selected in the view state reflects the view rednered in the
+      // diff.
+      assert.equal(select.mode, diffDisplay.viewMode);
+
+      // We will simulate a user change of the selected mode.
+      const newMode = 'UNIFIED_DIFF';
+
+      // Set the mode, and simulate the change event.
+      element.set('changeViewState.diffMode', newMode);
+
+      // Make sure the handler was called and the state is still coherent.
+      assert.equal(element._getDiffViewMode(), newMode);
+      assert.equal(element._getDiffViewMode(), select.mode);
+      assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
+    });
+
+    test('diff mode selector initializes from preferences', () => {
+      let resolvePrefs;
+      const prefsPromise = new Promise(resolve => {
+        resolvePrefs = resolve;
+      });
+      sinon.stub(element.$.restAPI, 'getPreferences')
+          .callsFake(() => prefsPromise);
+
+      // Attach a new gr-diff-view so we can intercept the preferences fetch.
+      const view = document.createElement('gr-diff-view');
+      blankFixture.instantiate().appendChild(view);
+      flush();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      // Receive the overriding preference.
+      resolvePrefs({default_diff_view: 'UNIFIED'});
+      flush();
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    test('diff mode selector should be hidden for binary', done => {
+      element._diff = {binary: true, content: []};
+
+      flush(() => {
+        const diffModeSelector = element.shadowRoot
+            .querySelector('.diffModeSelector');
+        assert.isTrue(diffModeSelector.classList.contains('hide'));
+        done();
+      });
+    });
+
+    suite('_commitRange', () => {
+      const change = {
+        _number: 42,
+        revisions: {
+          'commit-sha-1': {
+            _number: 1,
+            commit: {
+              parents: [{commit: 'sha-1-parent'}],
+            },
+          },
+          'commit-sha-2': {_number: 2, commit: {parents: []}},
+          'commit-sha-3': {_number: 3, commit: {parents: []}},
+          'commit-sha-4': {_number: 4, commit: {parents: []}},
+          'commit-sha-5': {
+            _number: 5,
+            commit: {
+              parents: [{commit: 'sha-5-parent'}],
+            },
+          },
+        },
+      };
+      setup(() => {
+        sinon.stub(element.$.diffHost, 'reload');
+        sinon.stub(element, '_initCursor');
+        element._change = change;
+        sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
+            change));
+      });
+
+      test('uses the patchNum and basePatchNum ', done => {
+        element.params = {
+          view: GerritNav.View.DIFF,
+          changeNum: '42',
+          patchNum: 4,
+          basePatchNum: 2,
+          path: '/COMMIT_MSG',
+        };
+        element._change = change;
+        flush(() => {
+          assert.deepEqual(element._commitRange, {
+            baseCommit: 'commit-sha-2',
+            commit: 'commit-sha-4',
+          });
+          done();
+        });
+      });
+
+      test('uses the parent when there is no base patch num ', done => {
+        element.params = {
+          view: GerritNav.View.DIFF,
+          changeNum: '42',
+          patchNum: 5,
+          path: '/COMMIT_MSG',
+        };
+        element._change = change;
+        flush(() => {
+          assert.deepEqual(element._commitRange, {
+            commit: 'commit-sha-5',
+            baseCommit: 'sha-5-parent',
+          });
+          done();
+        });
+      });
+    });
+
+    test('_initCursor', () => {
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Does nothing when params specify no cursor address:
+      element._initCursor(false);
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Does nothing when params specify side but no number:
+      element._initCursor(true);
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Revision hash: specifies lineNum but not side.
+
+      element._focusLineNum = 234;
+      element._initCursor(false);
+      assert.equal(element.$.cursor.initialLineNumber, 234);
+      assert.equal(element.$.cursor.side, 'right');
+
+      // Base hash: specifies lineNum and side.
+      element._focusLineNum = 345;
+      element._initCursor(true);
+      assert.equal(element.$.cursor.initialLineNumber, 345);
+      assert.equal(element.$.cursor.side, 'left');
+
+      // Specifies right side:
+      element._focusLineNum = 123;
+      element._initCursor(false);
+      assert.equal(element.$.cursor.initialLineNumber, 123);
+      assert.equal(element.$.cursor.side, 'right');
+    });
+
+    test('_getLineOfInterest', () => {
+      assert.isUndefined(element._getLineOfInterest(false));
+
+      element._focusLineNum = 12;
+      let result = element._getLineOfInterest(false);
+      assert.equal(result.number, 12);
+      assert.isNotOk(result.leftSide);
+
+      result = element._getLineOfInterest(true);
+      assert.equal(result.number, 12);
+      assert.isOk(result.leftSide);
+    });
+
+    test('_onLineSelected', () => {
+      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+      const replaceStateStub = sinon.stub(history, 'replaceState');
+      sinon.stub(element.$.cursor, 'getAddress')
+          .returns({number: 123, isLeftSide: false});
+
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {
+        basePatchNum: 3,
+        patchNum: 5,
+      };
+      const e = {};
+      const detail = {number: 123, side: 'right'};
+
+      element._onLineSelected(e, detail);
+
+      assert.isTrue(replaceStateStub.called);
+      assert.isTrue(getUrlStub.called);
+      assert.isFalse(getUrlStub.lastCall.args[6]);
+    });
+
+    test('line selected on left side', () => {
+      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+      const replaceStateStub = sinon.stub(history, 'replaceState');
+      sinon.stub(element.$.cursor, 'getAddress')
+          .returns({number: 123, isLeftSide: true});
+
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {
+        basePatchNum: 3,
+        patchNum: 5,
+      };
+      const e = {};
+      const detail = {number: 123, side: 'left'};
+
+      element._onLineSelected(e, detail);
+
+      assert.isTrue(replaceStateStub.called);
+      assert.isTrue(getUrlStub.called);
+      assert.isTrue(getUrlStub.lastCall.args[6]);
+    });
+
+    test('_getDiffViewMode', () => {
+      // No user prefs or change view state set.
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      // User prefs but no change view state set.
+      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+
+      // User prefs and change view state set.
+      element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    test('_handleToggleDiffMode', () => {
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const e = {preventDefault: () => {}};
+      // Initial state.
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      element._handleToggleDiffMode(e);
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+
+      element._handleToggleDiffMode(e);
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    suite('_initPatchRange', () => {
+      setup(async () => {
+        element.params = {
+          view: GerritView.DIFF,
+          changeNum: '42',
+          patchNum: 3,
+        };
+        await flush();
+      });
+      test('empty', () => {
+        sinon.stub(element, '_getCommentsForPath');
+        sinon.stub(element, '_getPaths').returns(new Map());
+        element._initPatchRange();
+        assert.equal(Object.keys(element._commentMap).length, 0);
+      });
+
+      test('has paths', () => {
+        sinon.stub(element, '_getFiles');
+        sinon.stub(element, '_getPaths').returns({
+          'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
+          'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
+        });
+        sinon.stub(element, '_getCommentsForPath').returns({meta: {}});
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: 3,
+          patchNum: 5,
+        };
+        element._initPatchRange();
+        assert.deepEqual(Object.keys(element._commentMap),
+            ['path/to/file/one.cpp', 'path-to/file/two.py']);
+      });
+    });
+
+    suite('_computeCommentSkips', () => {
+      test('empty file list', () => {
+        const commentMap = {
+          'path/one.jpg': true,
+          'path/three.wav': true,
+        };
+        const path = 'path/two.m4v';
+        const fileList = [];
+        const result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.isNull(result.previous);
+        assert.isNull(result.next);
+      });
+
+      test('finds skips', () => {
+        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
+        let path = fileList[1];
+        const commentMap = {};
+        commentMap[fileList[0]] = true;
+        commentMap[fileList[1]] = false;
+        commentMap[fileList[2]] = true;
+
+        let result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[0]);
+        assert.equal(result.next, fileList[2]);
+
+        commentMap[fileList[1]] = true;
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[0]);
+        assert.equal(result.next, fileList[2]);
+
+        path = fileList[0];
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.isNull(result.previous);
+        assert.equal(result.next, fileList[1]);
+
+        path = fileList[2];
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[1]);
+        assert.isNull(result.next);
+      });
+
+      suite('skip next/previous', () => {
+        let navToChangeStub;
+        let navToDiffStub;
+
+        setup(() => {
+          navToChangeStub = sinon.stub(element, '_navToChangeView');
+          navToDiffStub = sinon.stub(GerritNav, 'navigateToDiff');
+          element._files = getFilesFromFileList([
+            'path/one.jpg', 'path/two.m4v', 'path/three.wav',
+          ]);
+          element._patchRange = {patchNum: 2, basePatchNum: 1};
+        });
+
+        suite('_moveToPreviousFileWithComment', () => {
+          test('no skips', () => {
+            element._moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('no previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = false;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToPreviousFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+
+        suite('_moveToNextFileWithComment', () => {
+          test('no skips', () => {
+            element._moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('no previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = false;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToNextFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+      });
+    });
+
+    test('_computeEditMode', () => {
+      const callCompute = range => element._computeEditMode({base: range});
+      assert.isFalse(callCompute({}));
+      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
+      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
+      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
+    });
+
+    test('_computeFileNum', () => {
+      assert.equal(element._computeFileNum('/foo',
+          [{value: '/foo'}, {value: '/bar'}]), 1);
+      assert.equal(element._computeFileNum('/bar',
+          [{value: '/foo'}, {value: '/bar'}]), 2);
+    });
+
+    test('_computeFileNumClass', () => {
+      assert.equal(element._computeFileNumClass(0, []), '');
+      assert.equal(element._computeFileNumClass(1,
+          [{value: '/foo'}, {value: '/bar'}]), 'show');
+    });
+
+    test('_getReviewedStatus', () => {
+      const promises = [];
+      element.$.restAPI.getReviewedFiles.restore();
+
+      sinon.stub(element.$.restAPI, 'getReviewedFiles')
+          .returns(Promise.resolve(['path']));
+
+      promises.push(element._getReviewedStatus(true, null, null, 'path')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, null, null, 'path')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, 3, 5, 'path')
+          .then(reviewed => assert.isTrue(reviewed)));
+
+      return Promise.all(promises);
+    });
+
+    test('f open file dropdown', () => {
+      assert.isFalse(element.$.dropdown.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 70, null, 'f');
+      flush();
+      assert.isTrue(element.$.dropdown.$.dropdown.opened);
+    });
+
+    suite('blame', () => {
+      test('toggle blame with button', () => {
+        const toggleBlame = sinon.stub(
+            element.$.diffHost, 'loadBlame')
+            .callsFake(() => Promise.resolve());
+        MockInteractions.tap(element.$.toggleBlame);
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+      test('toggle blame with shortcut', () => {
+        const toggleBlame = sinon.stub(
+            element.$.diffHost, 'loadBlame').callsFake(() => Promise.resolve());
+        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+    });
+
+    suite('editMode behavior', () => {
+      setup(() => {
+        element._loggedIn = true;
+      });
+
+      const isVisible = el => {
+        assert.ok(el);
+        return getComputedStyle(el).getPropertyValue('display') !== 'none';
+      };
+
+      test('reviewed checkbox', () => {
+        sinon.stub(element, '_handlePatchChange');
+        element._patchRange = {patchNum: 1};
+        // Reviewed checkbox should be shown.
+        assert.isTrue(isVisible(element.$.reviewed));
+        element.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
+        flush();
+
+        assert.isFalse(isVisible(element.$.reviewed));
+      });
+    });
+
+    test('_paramsChanged sets in projectLookup', () => {
+      sinon.stub(element, '_initLineOfInterestAndCursor');
+      const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+      element._paramsChanged({
+        view: GerritNav.View.DIFF,
+        changeNum: 101,
+        project: 'test-project',
+        path: '',
+      });
+      assert.isTrue(setStub.calledOnce);
+      assert.isTrue(setStub.calledWith(101, 'test-project'));
+    });
+
+    test('shift+m navigates to next unreviewed file', () => {
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      element._reviewedFiles = new Set(['file1', 'file2']);
+      element._path = 'file1';
+      const reviewedStub = sinon.stub(element, '_setReviewed');
+      const navStub = sinon.stub(element, '_navToFile');
+      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      flush();
+
+      assert.isTrue(reviewedStub.lastCall.args[0]);
+      assert.deepEqual(navStub.lastCall.args, [
+        'file1',
+        ['file1', 'file3'],
+        1,
+      ]);
+    });
+
+    test('File change should trigger navigateToDiff once', done => {
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      sinon.stub(element, '_initLineOfInterestAndCursor');
+      sinon.stub(GerritNav, 'navigateToDiff');
+
+      // Load file1
+      element.params = {
+        view: GerritNav.View.DIFF,
+        patchNum: 1,
+        changeNum: 101,
+        project: 'test-project',
+        path: 'file1',
+      };
+      element._patchRange = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(1),
+      };
+      flush();
+      assert.isTrue(GerritNav.navigateToDiff.notCalled);
+
+      // Switch to file2
+      element._handleFileChange({detail: {value: 'file2'}});
+      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
+
+      // This is to mock the param change triggered by above navigate
+      element.params = {
+        view: GerritNav.View.DIFF,
+        patchNum: 1,
+        changeNum: 101,
+        project: 'test-project',
+        path: 'file2',
+      };
+      element._patchRange = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+
+      // No extra call
+      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
+      done();
+    });
+
+    test('_computeDownloadDropdownLinks', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/1' +
+              '/files/index.php/download?parent=1',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/1' +
+              '/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      const side = {
+        meta_a: true,
+        meta_b: true,
+      };
+
+      const base = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+
+      assert.deepEqual(
+          element._computeDownloadDropdownLinks(
+              'test', 12, base, 'index.php', side),
+          downloadLinks);
+    });
+
+    test('_computeDownloadDropdownLinks diff returns renamed', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/2' +
+              '/files/index2.php/download',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/3' +
+              '/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      const side = {
+        change_type: 'RENAMED',
+        meta_a: {
+          name: 'index2.php',
+        },
+        meta_b: true,
+      };
+
+      const base = {
+        patchNum: 3,
+        basePatchNum: 2,
+      };
+
+      assert.deepEqual(
+          element._computeDownloadDropdownLinks(
+              'test', 12, base, 'index.php', side),
+          downloadLinks);
+    });
+
+    test('_computeDownloadFileLink', () => {
+      const base = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+
+      assert.equal(
+          element._computeDownloadFileLink(
+              'test', 12, base, 'index.php', true),
+          '/changes/test~12/revisions/1/files/index.php/download?parent=1');
+
+      assert.equal(
+          element._computeDownloadFileLink(
+              'test', 12, base, 'index.php', false),
+          '/changes/test~12/revisions/1/files/index.php/download');
+    });
+
+    test('_computeDownloadPatchLink', () => {
+      assert.equal(
+          element._computeDownloadPatchLink(
+              'test', 12, {patchNum: 1}, 'index.php'),
+          '/changes/test~12/revisions/1/patch?zip&path=index.php');
+    });
+  });
+
+  suite('gr-diff-view tests unmodified files with comments', () => {
+    let element;
+    setup(() => {
+      const changedFiles = {
+        'file1.txt': {},
+        'a/b/test.c': {},
+      };
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({change: {}}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() { return Promise.resolve({}); },
+        getDiffChangeDetail() { return Promise.resolve({}); },
+        getChangeFiles() { return Promise.resolve(changedFiles); },
+        saveFileReviewed() { return Promise.resolve(); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+        getReviewedFiles() { return Promise.resolve([]); },
+      });
+      element = basicFixture.instantiate();
+      element._changeNum = '42';
+      return element._loadComments();
+    });
+
+    test('_getFiles add files with comments without changes', () => {
+      const patchChangeRecord = {
+        base: {
+          basePatchNum: 5,
+          patchNum: 10,
+        },
+      };
+      const changeComments = {
+        getPaths: sinon.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': {},
+              },
+            });
+          });
+    });
+  });
+});
+
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
deleted file mode 100644
index bfd063a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
+++ /dev/null
@@ -1,282 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the 'License');
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an 'AS IS' BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {GrDiffLine} from './gr-diff-line.js';
-
-/**
- * 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;
-
-  /**
-   * 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;
-
-  /**
-   * 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 {?HTMLElement} */
-  this.element = null;
-
-  /** @type {!Array<!GrDiffLine>} */
-  this.lines = [];
-  /** @type {!Array<!GrDiffLine>} */
-  this.adds = [];
-  /** @type {!Array<!GrDiffLine>} */
-  this.removes = [];
-
-  /** Both start and end line are inclusive. */
-  this.lineRange = {
-    left: {start: null, end: null},
-    right: {start: null, end: null},
-  };
-
-  if (opt_lines) {
-    opt_lines.forEach(this.addLine, this);
-  }
-}
-
-/** @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];
-  }
-
-  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;
-    }
-  }
-
-  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;
-    }
-  }
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
new file mode 100644
index 0000000..588b9d1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -0,0 +1,371 @@
+/**
+ * @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 {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
+import {Side} from '../../../constants/constants';
+
+export enum GrDiffGroupType {
+  /** Unchanged context. */
+  BOTH = 'both',
+
+  /** A widget used to show more context. */
+  CONTEXT_CONTROL = 'contextControl',
+
+  /** Added, removed or modified chunk. */
+  DELTA = 'delta',
+}
+
+export interface GrDiffLinePair {
+  left: GrDiffLine;
+  right: GrDiffLine;
+}
+
+interface Range {
+  start: number | null;
+  end: number | null;
+}
+
+export interface GrDiffGroupRange {
+  left: Range;
+  right: Range;
+}
+
+export function rangeBySide(range: GrDiffGroupRange, side: Side): Range {
+  return side === Side.LEFT ? range.left : range.right;
+}
+
+/**
+ * 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 groups Common groups, ordered by their line ranges.
+ * @param 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 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.
+ */
+export function hideInContextControl(
+  groups: GrDiffGroup[],
+  hiddenStart: number,
+  hiddenEnd: number
+): GrDiffGroup[] {
+  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: GrDiffGroup[] = [];
+  let hidden = groups;
+  let after: GrDiffGroup[] = [];
+
+  const numHidden = hiddenEnd - hiddenStart;
+
+  // Only collapse if there is more than 1 line to be hidden.
+  if (numHidden > 1) {
+    if (hiddenStart) {
+      [before, hidden] = _splitCommonGroups(hidden, hiddenStart);
+    }
+    if (hiddenEnd) {
+      let beforeLength = 0;
+      if (before.length > 0) {
+        const beforeStart = before[0].lineRange.left.start || 0;
+        const beforeEnd = before[before.length - 1].lineRange.left.end || 0;
+        beforeLength = beforeEnd - beforeStart + 1;
+      }
+      [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
+    }
+  } else {
+    [hidden, after] = [[], hidden];
+  }
+
+  const result = [...before];
+  if (hidden.length) {
+    const ctxGroup = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL, []);
+    ctxGroup.contextGroups = hidden;
+    result.push(ctxGroup);
+  }
+  result.push(...after);
+  return result;
+}
+
+/**
+ * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
+ * used in function _splitCommonGroups
+ * 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 group The group to be split in two
+ * @param leftSplit The line number relative to the split on the left side
+ * @param rightSplit The line number relative to the split on the right side
+ * @return two new groups, one before the split and another after it
+ */
+function _splitGroupInTwo(
+  group: GrDiffGroup,
+  leftSplit: number,
+  rightSplit: number
+) {
+  let beforeSplit: GrDiffGroup | undefined;
+  let afterSplit: GrDiffGroup | undefined;
+  // split line is in the middle of a group, we need to break the group
+  // in lines before and after the split.
+  if (group.skip) {
+    // Currently we assume skip chunks "refuse" to be split. Expanding this
+    // group will in the future mean load more data - and therefore we want to
+    // fire an event when user wants to do it.
+    const closerToStartThanEnd =
+      leftSplit - (group.lineRange.left.start || 0) <
+      (group.lineRange.right.end || 0) - leftSplit;
+    if (closerToStartThanEnd) {
+      afterSplit = group;
+    } else {
+      beforeSplit = group;
+    }
+  } else {
+    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) {
+      beforeSplit =
+        before.length === group.lines.length
+          ? group
+          : group.cloneWithLines(before);
+    }
+    if (after.length) {
+      afterSplit =
+        after.length === group.lines.length
+          ? group
+          : group.cloneWithLines(after);
+    }
+  }
+  return {beforeSplit, afterSplit};
+}
+
+/**
+ * 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 split A line number offset relative to the first group's
+ *     start line at which the groups should be split.
+ * @return The outer array has 2 elements, the
+ *   list of groups before and the list of groups after the split.
+ */
+function _splitCommonGroups(
+  groups: GrDiffGroup[],
+  split: number
+): GrDiffGroup[][] {
+  if (groups.length === 0) return [[], []];
+  const leftSplit = (groups[0].lineRange.left.start || 0) + split;
+  const rightSplit = (groups[0].lineRange.right.start || 0) + split;
+
+  const beforeGroups = [];
+  const afterGroups = [];
+  for (const group of groups) {
+    const isCompletelyBefore =
+      (group.lineRange.left.end || 0) < leftSplit ||
+      (group.lineRange.right.end || 0) < rightSplit;
+    const isCompletelyAfter =
+      leftSplit <= (group.lineRange.left.start || 0) ||
+      rightSplit <= (group.lineRange.right.start || 0);
+    if (isCompletelyBefore) {
+      beforeGroups.push(group);
+    } else if (isCompletelyAfter) {
+      afterGroups.push(group);
+    } else {
+      const {beforeSplit, afterSplit} = _splitGroupInTwo(
+        group,
+        leftSplit,
+        rightSplit
+      );
+      if (beforeSplit) {
+        beforeGroups.push(beforeSplit);
+      }
+      if (afterSplit) {
+        afterGroups.push(afterSplit);
+      }
+    }
+  }
+  return [beforeGroups, afterGroups];
+}
+
+/**
+ * A chunk of the diff that should be rendered together.
+ *
+ * @constructor
+ * @param {!GrDiffGroupType} type
+ * @param {!Array<!GrDiffLine>=} opt_lines
+ */
+export class GrDiffGroup {
+  constructor(readonly type: GrDiffGroupType, lines: GrDiffLine[] = []) {
+    lines.forEach((line: GrDiffLine) => this.addLine(line));
+  }
+
+  dueToRebase = false;
+
+  dueToMove = false;
+
+  /**
+   * True means all changes in this line are whitespace changes that should
+   * not be highlighted as changed as per the user settings.
+   */
+  ignoredWhitespaceOnly = false;
+
+  /**
+   * True means it should not be collapsed (because it was in the URL, or
+   * there is a comment on that line)
+   */
+  keyLocation = false;
+
+  element?: HTMLElement;
+
+  lines: GrDiffLine[] = [];
+
+  adds: GrDiffLine[] = [];
+
+  removes: GrDiffLine[] = [];
+
+  contextGroups: GrDiffGroup[] = [];
+
+  skip?: number;
+
+  /** Both start and end line are inclusive. */
+  lineRange: GrDiffGroupRange = {
+    left: {start: null, end: null},
+    right: {start: null, end: null},
+  };
+
+  /**
+   * 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.
+   */
+  cloneWithLines(lines: GrDiffLine[]): GrDiffGroup {
+    const group = new GrDiffGroup(this.type, lines);
+    group.dueToRebase = this.dueToRebase;
+    group.ignoredWhitespaceOnly = this.ignoredWhitespaceOnly;
+    return group;
+  }
+
+  addLine(line: GrDiffLine) {
+    this.lines.push(line);
+
+    const notDelta =
+      this.type === GrDiffGroupType.BOTH ||
+      this.type === GrDiffGroupType.CONTEXT_CONTROL;
+    if (
+      notDelta &&
+      (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.REMOVE)
+    ) {
+      throw Error('Cannot add delta line to a non-delta group.');
+    }
+
+    if (line.type === GrDiffLineType.ADD) {
+      this.adds.push(line);
+    } else if (line.type === GrDiffLineType.REMOVE) {
+      this.removes.push(line);
+    }
+    this._updateRange(line);
+  }
+
+  getSideBySidePairs(): GrDiffLinePair[] {
+    if (
+      this.type === GrDiffGroupType.BOTH ||
+      this.type === GrDiffGroupType.CONTEXT_CONTROL
+    ) {
+      return this.lines.map(line => {
+        return {
+          left: line,
+          right: line,
+        };
+      });
+    }
+
+    const pairs: GrDiffLinePair[] = [];
+    let i = 0;
+    let j = 0;
+    while (i < this.removes.length || j < this.adds.length) {
+      pairs.push({
+        left: this.removes[i] || BLANK_LINE,
+        right: this.adds[j] || BLANK_LINE,
+      });
+      i++;
+      j++;
+    }
+    return pairs;
+  }
+
+  _updateRange(line: GrDiffLine) {
+    if (line.beforeNumber === 'FILE' || line.afterNumber === 'FILE') {
+      return;
+    }
+
+    if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.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 === GrDiffLineType.REMOVE ||
+      line.type === GrDiffLineType.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;
+      }
+    }
+  }
+}
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
deleted file mode 100644
index d50a7f4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
+++ /dev/null
@@ -1,209 +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">
-<meta charset="utf-8">
-<title>gr-diff-group</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<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';
-
-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},
-    });
-
-    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},
-    ]);
-  });
-
-  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-group_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
new file mode 100644
index 0000000..3423834
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
@@ -0,0 +1,220 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line.js';
+import {GrDiffGroup, GrDiffGroupType, hideInContextControl} from './gr-diff-group.js';
+
+suite('gr-diff-group tests', () => {
+  test('delta line pairs', () => {
+    let group = new GrDiffGroup(GrDiffGroupType.DELTA);
+    const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
+    const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
+    const l3 = new GrDiffLine(GrDiffLineType.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},
+    });
+
+    let pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: BLANK_LINE, right: l2},
+    ]);
+
+    group = new GrDiffGroup(GrDiffGroupType.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: BLANK_LINE, right: l2},
+    ]);
+  });
+
+  test('group/header line pairs', () => {
+    const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
+    const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
+    const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
+
+    let group = new GrDiffGroup(GrDiffGroupType.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(GrDiffGroupType.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(GrDiffLineType.ADD);
+    const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
+    const l3 = new GrDiffLine(GrDiffLineType.BOTH);
+
+    let group = new GrDiffGroup(GrDiffGroupType.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(GrDiffGroupType.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(GrDiffGroupType.BOTH, [
+          new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+          new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+          new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+        ]),
+        new GrDiffGroup(GrDiffGroupType.DELTA, [
+          new GrDiffLine(GrDiffLineType.REMOVE, 8),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+          new GrDiffLine(GrDiffLineType.REMOVE, 9),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+          new GrDiffLine(GrDiffLineType.REMOVE, 10),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+        ]),
+        new GrDiffGroup(GrDiffGroupType.BOTH, [
+          new GrDiffLine(GrDiffLineType.BOTH, 11, 13),
+          new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+          new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+        ]),
+      ];
+    });
+
+    test('hides hidden groups in context control', () => {
+      const collapsedGroups = hideInContextControl(groups, 3, 6);
+      assert.equal(collapsedGroups.length, 3);
+
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[1].contextGroups.length, 1);
+      assert.equal(collapsedGroups[1].contextGroups[0], groups[1]);
+
+      assert.equal(collapsedGroups[2], groups[2]);
+    });
+
+    test('splits partially hidden groups', () => {
+      const collapsedGroups = hideInContextControl(groups, 4, 7);
+      assert.equal(collapsedGroups.length, 4);
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroupType.DELTA);
+      assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
+      assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+
+      assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[2].contextGroups.length, 2);
+
+      assert.equal(
+          collapsedGroups[2].contextGroups[0].type,
+          GrDiffGroupType.DELTA);
+      assert.deepEqual(
+          collapsedGroups[2].contextGroups[0].adds,
+          groups[1].adds.slice(1));
+      assert.deepEqual(
+          collapsedGroups[2].contextGroups[0].removes,
+          groups[1].removes.slice(1));
+
+      assert.equal(
+          collapsedGroups[2].contextGroups[1].type,
+          GrDiffGroupType.BOTH);
+      assert.deepEqual(
+          collapsedGroups[2].contextGroups[1].lines,
+          [groups[2].lines[0]]);
+
+      assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
+      assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
+    });
+
+    suite('with skip chunks', () => {
+      setup(() => {
+        const skipGroup = new GrDiffGroup(GrDiffGroupType.BOTH);
+        skipGroup.skip = 60;
+        skipGroup.lineRange = {
+          left: {start: 8, end: 67},
+          right: {start: 10, end: 69},
+        };
+        groups = [
+          new GrDiffGroup(GrDiffGroupType.BOTH, [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ]),
+          skipGroup,
+          new GrDiffGroup(GrDiffGroupType.BOTH, [
+            new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+            new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+            new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+          ]),
+        ];
+      });
+
+      test('refuses to split skip group when closer to before', () => {
+        const collapsedGroups = hideInContextControl(groups, 4, 10);
+        assert.deepEqual(groups, collapsedGroups);
+      });
+    });
+
+    test('groups unchanged if the hidden range is empty', () => {
+      assert.deepEqual(
+          hideInContextControl(groups, 0, 0), groups);
+    });
+
+    test('groups unchanged if there is only 1 line to hide', () => {
+      assert.deepEqual(
+          hideInContextControl(groups, 3, 4), groups);
+    });
+  });
+});
+
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
deleted file mode 100644
index 70387ca..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ /dev/null
@@ -1,73 +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.
- */
-
-/**
- * @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;
-
-  /** @type {number|string} */
-  this.beforeNumber = opt_beforeLine || 0;
-
-  /** @type {number|string} */
-  this.afterNumber = opt_afterLine || 0;
-
-  /** @type {boolean} */
-  this.hasIntralineInfo = false;
-
-  /** @type {!Array<GrDiffLine.Highlights>} */
-  this.highlights = [];
-
-  /** @type {?Array<Object>} ?Array<!GrDiffGroup> */
-  this.contextGroups = null;
-
-  this.text = '';
-}
-
-/** @enum {string} */
-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;
-
-GrDiffLine.FILE = 'FILE';
-
-GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
new file mode 100644
index 0000000..2d80213
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
@@ -0,0 +1,62 @@
+/**
+ * @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 const FILE = 'FILE';
+export type LineNumber = number | 'FILE';
+
+export enum GrDiffLineType {
+  ADD = 'add',
+  BOTH = 'both',
+  BLANK = 'blank',
+  REMOVE = 'remove',
+}
+
+export class GrDiffLine {
+  constructor(
+    readonly type: GrDiffLineType,
+    public beforeNumber: LineNumber = 0,
+    public afterNumber: LineNumber = 0
+  ) {}
+
+  hasIntralineInfo = false;
+
+  highlights: Highlights[] = [];
+
+  text = '';
+
+  // TODO(TS): remove this properties
+  static readonly Type = GrDiffLineType;
+
+  static readonly File = FILE;
+}
+
+/**
+ * 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.
+ */
+export interface Highlights {
+  contentIndex: number;
+  startIndex: number;
+  endIndex?: number;
+}
+
+export const BLANK_LINE = new GrDiffLine(GrDiffLineType.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
deleted file mode 100644
index 7eee071..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @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-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
new file mode 100644
index 0000000..8984dc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -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 {CommentRange} from '../../../types/common';
+import {FILE, LineNumber} from './gr-diff-line';
+
+/**
+ * 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.
+ */
+export function rangesEqual(a?: CommentRange, b?: CommentRange): boolean {
+  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
+  );
+}
+
+export function getLineNumber(lineEl?: Element | null): LineNumber | null {
+  if (!lineEl) return null;
+  const lineNumberStr = lineEl.getAttribute('data-value');
+  if (!lineNumberStr) return null;
+  if (lineNumberStr === FILE) return FILE;
+  const lineNumber = Number(lineNumberStr);
+  return Number.isInteger(lineNumber) ? lineNumber : null;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
deleted file mode 100644
index 06ffabc..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ /dev/null
@@ -1,998 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 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 NO_NEWLINE_BASE = 'No newline at end of base file.';
-const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
-
-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;
-
-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'; }
-  /**
-   * 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
-   */
-
-  /**
-   * 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
-   */
-
-  static get properties() {
-    return {
-      changeNum: String,
-      noAutoRender: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      patchRange: Object,
-      path: {
-        type: String,
-        observer: '_pathObserver',
-      },
-      prefs: {
-        type: Object,
-        observer: '_prefsObserver',
-      },
-      projectName: String,
-      displayLine: {
-        type: Boolean,
-        value: false,
-      },
-      isImageDiff: {
-        type: Boolean,
-      },
-      commitRange: Object,
-      hidden: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      noRenderOnPrefsChange: Boolean,
-      /** @type {!Array<!Gerrit.HoveredRange>} */
-      _commentRanges: {
-        type: Array,
-        value: () => [],
-      },
-      /** @type {!Array<!Gerrit.CoverageRange>} */
-      coverageRanges: {
-        type: Array,
-        value: () => [],
-      },
-      lineWrapping: {
-        type: Boolean,
-        value: false,
-        observer: '_lineWrappingObserver',
-      },
-      viewMode: {
-        type: String,
-        value: DiffViewMode.SIDE_BY_SIDE,
-        observer: '_viewModeObserver',
-      },
-
-      /** @type {?Gerrit.LineOfInterest} */
-      lineOfInterest: Object,
-
-      loading: {
-        type: Boolean,
-        value: false,
-        observer: '_loadingChanged',
-      },
-
-      loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      diff: {
-        type: Object,
-        observer: '_diffChanged',
-      },
-      _diffHeaderItems: {
-        type: Array,
-        value: [],
-        computed: '_computeDiffHeaderItems(diff.*)',
-      },
-      _diffTableClass: {
-        type: String,
-        value: '',
-      },
-      /** @type {?Object} */
-      baseImage: Object,
-      /** @type {?Object} */
-      revisionImage: Object,
-
-      /**
-       * Whether the safety check for large diffs when whole-file is set has
-       * been bypassed. If the value is null, then the safety has not been
-       * bypassed. If the value is a number, then that number represents the
-       * context preference to use when rendering the bypassed diff.
-       *
-       * @type {number|null}
-       */
-      _safetyBypass: {
-        type: Number,
-        value: null,
-      },
-
-      _showWarning: Boolean,
-
-      /** @type {?string} */
-      errorMessage: {
-        type: String,
-        value: null,
-      },
-
-      /** @type {?Object} */
-      blame: {
-        type: Object,
-        value: null,
-        observer: '_blameChanged',
-      },
-
-      parentIndex: Number,
-
-      showNewlineWarningLeft: {
-        type: Boolean,
-        value: false,
-      },
-      showNewlineWarningRight: {
-        type: Boolean,
-        value: false,
-      },
-
-      _newlineWarning: {
-        type: String,
-        computed: '_computeNewlineWarning(' +
-            'showNewlineWarningLeft, showNewlineWarningRight)',
-      },
-
-      _diffLength: Number,
-
-      /**
-       * Observes comment nodes added or removed after the initial render.
-       * Can be used to unregister when the entire diff is (re-)rendered or upon
-       * detachment.
-       *
-       * @type {?PolymerDomApi.ObserveHandle}
-       */
-      _incrementalNodeObserver: Object,
-
-      /**
-       * Observes comment nodes added or removed at any point.
-       * Can be used to unregister upon detachment.
-       *
-       * @type {?PolymerDomApi.ObserveHandle}
-       */
-      _nodeObserver: Object,
-
-      /** Set by Polymer. */
-      isAttached: Boolean,
-      layers: Array,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_enableSelectionObserver(loggedIn, isAttached)',
-    ];
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('create-range-comment',
-        e => this._handleCreateRangeComment(e));
-    this.addEventListener('render-content',
-        () => this._handleRenderContent());
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._observeNodes();
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this._unobserveIncrementalNodes();
-    this._unobserveNodes();
-  }
-
-  showNoChangeMessage(loading, prefs, diffLength) {
-    return !loading &&
-      prefs && prefs.ignore_whitespace !== 'IGNORE_NONE' &&
-      diffLength === 0;
-  }
-
-  _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;
-  }
-
-  // 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}));
-      });
-    }
-  }
-
-  /** Cancel any remaining diff builder rendering work. */
-  cancel() {
-    this.$.diffBuilder.cancel();
-    this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
-  }
-
-  /** @return {!Array<!HTMLElement>} */
-  getCursorStops() {
-    if (this.hidden && this.noAutoRender) {
-      return [];
-    }
-
-    return Array.from(
-        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'));
-  }
-
-  /** @return {boolean} */
-  isRangeSelected() {
-    return !!this.$.highlights.selectedRange;
-  }
-
-  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 (getHiddenScroll()) {
-      classes.push('hiddenscroll');
-    }
-    if (loggedIn) {
-      classes.push('canComment');
-    }
-    if (displayLine) {
-      classes.push('displayLine');
-    }
-    return classes.join(' ');
-  }
-
-  _handleTap(e) {
-    const el = dom(e).localTarget;
-
-    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); }
-    }
-  }
-
-  _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; }
-
-    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);
-  }
-
-  createRangeComment() {
-    if (!this.isRangeSelected()) {
-      throw Error('Selection is needed for new range comment');
-    }
-    const {side, range} = this.$.highlights.selectedRange;
-    this._createCommentForSelection(side, range);
-  }
-
-  _createCommentForSelection(side, range) {
-    const lineNum = range.end_line;
-    const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
-    if (this._isValidElForComment(lineEl)) {
-      this._createComment(lineEl, lineNum, side, range);
-    }
-  }
-
-  _handleCreateRangeComment(e) {
-    const range = e.detail.range;
-    const side = e.detail.side;
-    this._createCommentForSelection(side, range);
-  }
-
-  /** @return {boolean} */
-  _isValidElForComment(el) {
-    if (!this.loggedIn) {
-      this.dispatchEvent(new CustomEvent('show-auth-required', {
-        composed: true, bubbles: true,
-      }));
-      return false;
-    }
-    const patchNum = el.classList.contains(DiffSide.LEFT) ?
-      this.patchRange.basePatchNum :
-      this.patchRange.patchNum;
-
-    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';
-      }
-    } 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._cleanup();
-      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() {
-    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._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;
-        }
-        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.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
new file mode 100644
index 0000000..e57b336
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -0,0 +1,1083 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../gr-diff-builder/gr-diff-builder-element';
+import '../gr-diff-highlight/gr-diff-highlight';
+import '../gr-diff-selection/gr-diff-selection';
+import '../gr-syntax-themes/gr-syntax-theme';
+import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {htmlTemplate} from './gr-diff_html';
+import {FILE, LineNumber} from './gr-diff-line';
+import {getLineNumber, rangesEqual} from './gr-diff-utils';
+import {getHiddenScroll} from '../../../scripts/hiddenscroll';
+import {isMergeParent, patchNumEquals} from '../../../utils/patch-set-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+  BlameInfo,
+  CommentRange,
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffPreferencesInfoKey,
+  EditPatchSetNum,
+  ImageInfo,
+  ParentPatchSetNum,
+  PatchRange,
+} from '../../../types/common';
+import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
+import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+import {
+  CoverageRange,
+  DiffLayer,
+  PolymerDomWrapper,
+} from '../../../types/types';
+import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {DiffViewMode, Side} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {getContentEditableRange} from '../../../utils/safari-selection-util';
+
+import {isSafari} from '../../../utils/dom-util';
+
+const NO_NEWLINE_BASE = 'No newline at end of base file.';
+const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+
+const LARGE_DIFF_THRESHOLD_LINES = 10000;
+const FULL_CONTEXT = -1;
+const LIMITED_CONTEXT = 10;
+
+function getSide(threadEl: GrCommentThread): Side {
+  const sideAtt = threadEl.getAttribute('comment-side');
+  if (!sideAtt) throw Error('comment thread without side');
+  if (sideAtt !== 'left' && sideAtt !== 'right')
+    throw Error(`unexpected value for side: ${sideAtt}`);
+  return sideAtt as Side;
+}
+
+function isThreadEl(node: Node): node is GrCommentThread {
+  return (
+    node.nodeType === Node.ELEMENT_NODE &&
+    (node as Element).classList.contains('comment-thread')
+  );
+}
+
+// TODO(TS): Replace by proper GrCommentThread once converted.
+type GrCommentThread = PolymerElement & {
+  rootId: string;
+  range: CommentRange;
+};
+
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+/**
+ * 72 is the unofficial 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';
+
+export interface LineOfInterest {
+  number: number;
+  leftSide: boolean;
+}
+
+export interface GrDiff {
+  $: {
+    highlights: GrDiffHighlight;
+    diffBuilder: GrDiffBuilderElement;
+    diffTable: HTMLTableElement;
+  };
+}
+
+@customElement('gr-diff')
+export class GrDiff extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * 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
+   */
+
+  /**
+   * 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
+   */
+
+  @property({type: String})
+  changeNum?: string;
+
+  @property({type: Boolean})
+  noAutoRender = false;
+
+  @property({type: Object})
+  patchRange?: PatchRange;
+
+  @property({type: String, observer: '_pathObserver'})
+  path?: string;
+
+  @property({type: Object, observer: '_prefsObserver'})
+  prefs?: DiffPreferencesInfo;
+
+  @property({type: Boolean})
+  displayLine = false;
+
+  @property({type: Boolean})
+  isImageDiff?: boolean;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  hidden = false;
+
+  @property({type: Boolean})
+  noRenderOnPrefsChange?: boolean;
+
+  @property({type: Array})
+  _commentRanges: CommentRangeLayer[] = [];
+
+  @property({type: Array})
+  coverageRanges: CoverageRange[] = [];
+
+  @property({type: Boolean, observer: '_lineWrappingObserver'})
+  lineWrapping = false;
+
+  @property({type: String, observer: '_viewModeObserver'})
+  viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  @property({type: Object})
+  lineOfInterest?: LineOfInterest;
+
+  /** True when diff is changed, until the content is done rendering. */
+  @property({type: Boolean})
+  _loading = false;
+
+  @property({type: Boolean})
+  loggedIn = false;
+
+  @property({type: Object, observer: '_diffChanged'})
+  diff?: DiffInfo;
+
+  @property({type: Array, computed: '_computeDiffHeaderItems(diff.*)'})
+  _diffHeaderItems: unknown[] = [];
+
+  @property({type: String})
+  _diffTableClass = '';
+
+  @property({type: Object})
+  baseImage?: ImageInfo;
+
+  @property({type: Object})
+  revisionImage?: ImageInfo;
+
+  /**
+   * In order to allow multi-select in Safari browsers, a workaround is required
+   * to trigger 'beforeinput' events to get a list of static ranges. This is
+   * obtained by making the content of the diff table "contentEditable".
+   */
+  @property({type: Boolean})
+  isContentEditable = isSafari();
+
+  /**
+   * Whether the safety check for large diffs when whole-file is set has
+   * been bypassed. If the value is null, then the safety has not been
+   * bypassed. If the value is a number, then that number represents the
+   * context preference to use when rendering the bypassed diff.
+   */
+  @property({type: Number})
+  _safetyBypass: number | null = null;
+
+  @property({type: Boolean})
+  _showWarning?: boolean;
+
+  @property({type: String})
+  errorMessage: string | null = null;
+
+  @property({type: Object, observer: '_blameChanged'})
+  blame: BlameInfo[] | null = null;
+
+  @property({type: Number})
+  parentIndex?: number;
+
+  @property({type: Boolean})
+  showNewlineWarningLeft = false;
+
+  @property({type: Boolean})
+  showNewlineWarningRight = false;
+
+  @property({type: Boolean})
+  useNewContextControls = false;
+
+  @property({
+    type: String,
+    computed:
+      '_computeNewlineWarning(' +
+      'showNewlineWarningLeft, showNewlineWarningRight)',
+  })
+  _newlineWarning: string | null = null;
+
+  @property({type: Number})
+  _diffLength?: number;
+
+  /**
+   * Observes comment nodes added or removed after the initial render.
+   * Can be used to unregister when the entire diff is (re-)rendered or upon
+   * detachment.
+   */
+  @property({type: Object})
+  _incrementalNodeObserver?: FlattenedNodesObserver;
+
+  /**
+   * Observes comment nodes added or removed at any point.
+   * Can be used to unregister upon detachment.
+   */
+  @property({type: Object})
+  _nodeObserver?: FlattenedNodesObserver;
+
+  @property({type: Array})
+  layers?: DiffLayer[];
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('create-range-comment', (e: Event) =>
+      this._handleCreateRangeComment(e as CustomEvent)
+    );
+    this.addEventListener('render-content', () => this._handleRenderContent());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._observeNodes();
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this._unobserveIncrementalNodes();
+    this._unobserveNodes();
+  }
+
+  showNoChangeMessage(
+    loading?: boolean,
+    prefs?: DiffPreferencesInfo,
+    diffLength?: number,
+    diff?: DiffInfo
+  ) {
+    return (
+      !loading &&
+      diff &&
+      !diff.binary &&
+      prefs &&
+      prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+      diffLength === 0
+    );
+  }
+
+  @observe('loggedIn', 'isAttached')
+  _enableSelectionObserver(loggedIn: boolean, isAttached?: boolean) {
+    // Polymer 2: check for undefined
+    if ([loggedIn, isAttached].includes(undefined)) {
+      return;
+    }
+
+    if (loggedIn && isAttached) {
+      this.listen(document, 'selectionchange', '_handleSelectionChange');
+      this.listen(document, 'mouseup', '_handleMouseUp');
+    } else {
+      this.unlisten(document, '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() {
+    // 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 instanceof ShadowRoot && this.root.getSelection
+      ? this.root.getSelection()
+      : isSafari()
+      ? getContentEditableRange()
+      : document.getSelection();
+  }
+
+  _observeNodes() {
+    this._nodeObserver = (dom(this) as PolymerDomWrapper).observeNodes(info => {
+      const addedThreadEls = info.addedNodes.filter(isThreadEl);
+      const removedThreadEls = info.removedNodes.filter(isThreadEl);
+      this._updateRanges(addedThreadEls, removedThreadEls);
+      this._redispatchHoverEvents(addedThreadEls);
+    });
+  }
+
+  // TODO(brohlfs): Rewrite gr-diff to be agnostic of GrCommentThread, because
+  // other users of gr-diff may use different comment widgets.
+  _updateRanges(
+    addedThreadEls: GrCommentThread[],
+    removedThreadEls: GrCommentThread[]
+  ) {
+    function commentRangeFromThreadEl(
+      threadEl: GrCommentThread
+    ): CommentRangeLayer | undefined {
+      const side = getSide(threadEl);
+
+      const rangeAtt = threadEl.getAttribute('range');
+      if (!rangeAtt) return undefined;
+      const range = JSON.parse(rangeAtt) as CommentRange;
+
+      return {side, range, hovering: false, rootId: threadEl.rootId};
+    }
+
+    // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
+    const addedCommentRanges = addedThreadEls
+      .map(commentRangeFromThreadEl)
+      .filter(range => !!range) as CommentRangeLayer[];
+    const removedCommentRanges = removedThreadEls
+      .map(commentRangeFromThreadEl)
+      .filter(range => !!range) as CommentRangeLayer[];
+    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
+   */
+  _computeKeyLocations() {
+    const keyLocations: KeyLocations = {left: {}, right: {}};
+    if (this.lineOfInterest) {
+      const side = this.lineOfInterest.leftSide ? Side.LEFT : Side.RIGHT;
+      keyLocations[side][this.lineOfInterest.number] = true;
+    }
+    const threadEls = (dom(this) as PolymerDomWrapper)
+      .getEffectiveChildNodes()
+      .filter(isThreadEl);
+
+    for (const threadEl of threadEls) {
+      const side = getSide(threadEl);
+      const lineNum = Number(threadEl.getAttribute('line-num')) || FILE;
+      const commentRange = threadEl.range || {};
+      keyLocations[side][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[side][commentRange.start_line] = true;
+      }
+    }
+    return keyLocations;
+  }
+
+  // Dispatch events that are handled by the gr-diff-highlight.
+  _redispatchHoverEvents(addedThreadEls: GrCommentThread[]) {
+    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,
+          })
+        );
+      });
+    }
+  }
+
+  /** Cancel any remaining diff builder rendering work. */
+  cancel() {
+    this.$.diffBuilder.cancel();
+    this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
+  }
+
+  getCursorStops(): HTMLElement[] {
+    if (this.hidden && this.noAutoRender) return [];
+    if (!this.root) return [];
+
+    return Array.from(
+      this.root.querySelectorAll<HTMLElement>(
+        ':not(.contextControl) > .diff-row'
+      )
+    ).filter(tr => tr.querySelector('button'));
+  }
+
+  isRangeSelected() {
+    return !!this.$.highlights.selectedRange;
+  }
+
+  toggleLeftDiff() {
+    this.toggleClass('no-left');
+  }
+
+  _blameChanged(newValue?: BlameInfo[] | null) {
+    if (newValue === undefined) return;
+    this.$.diffBuilder.setBlame(newValue);
+    if (newValue) {
+      this.classList.add('showBlame');
+    } else {
+      this.classList.remove('showBlame');
+    }
+  }
+
+  _computeContainerClass(
+    loggedIn: boolean,
+    viewMode: DiffViewMode,
+    displayLine: boolean
+  ) {
+    const classes = ['diffContainer'];
+    if (viewMode === DiffViewMode.UNIFIED) classes.push('unified');
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) classes.push('sideBySide');
+    if (getHiddenScroll()) classes.push('hiddenscroll');
+    if (loggedIn) classes.push('canComment');
+    if (displayLine) classes.push('displayLine');
+    return classes.join(' ');
+  }
+
+  _handleTap(e: CustomEvent) {
+    const el = (dom(e) as EventApi).localTarget as Element;
+
+    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);
+      }
+    }
+  }
+
+  _selectLine(el: Element) {
+    this.dispatchEvent(
+      new CustomEvent('line-selected', {
+        detail: {
+          side: el.classList.contains('left') ? Side.LEFT : Side.RIGHT,
+          number: el.getAttribute('data-value'),
+          path: this.path,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  addDraftAtLine(el: Element) {
+    this._selectLine(el);
+    if (!this._isValidElForComment(el)) {
+      return;
+    }
+
+    const lineNum = getLineNumber(el);
+    if (lineNum === null) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: 'Invalid line number'},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+
+    // TODO(TS): existing logic always pass undefined lineNum
+    // for file level comment, the drafts API will reject the
+    // request if file level draft contains the `line: 'FILE'` field
+    // probably should do this inside of the _createComment, this
+    // is just to keep existing behavior.
+    this._createComment(el, lineNum === FILE ? undefined : lineNum);
+  }
+
+  createRangeComment() {
+    if (!this.isRangeSelected()) {
+      throw Error('Selection is needed for new range comment');
+    }
+    const selectedRange = this.$.highlights.selectedRange;
+    if (!selectedRange) throw Error('selected range not set');
+    const {side, range} = selectedRange;
+    this._createCommentForSelection(side, range);
+  }
+
+  _createCommentForSelection(side: Side, range: CommentRange) {
+    const lineNum = range.end_line;
+    const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+    if (lineEl && this._isValidElForComment(lineEl)) {
+      this._createComment(lineEl, lineNum, side, range);
+    }
+  }
+
+  _handleCreateRangeComment(e: CustomEvent) {
+    const range = e.detail.range;
+    const side = e.detail.side;
+    this._createCommentForSelection(side, range);
+  }
+
+  _isValidElForComment(el: Element) {
+    if (!this.loggedIn) {
+      this.dispatchEvent(
+        new CustomEvent('show-auth-required', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return false;
+    }
+    if (!this.patchRange) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: 'Cannot create comment. Patch range undefined.'},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return false;
+    }
+    const patchNum = el.classList.contains(Side.LEFT)
+      ? this.patchRange.basePatchNum
+      : this.patchRange.patchNum;
+
+    const isEdit = patchNumEquals(patchNum, EditPatchSetNum);
+    const isEditBase =
+      patchNumEquals(patchNum, ParentPatchSetNum) &&
+      patchNumEquals(this.patchRange.patchNum, EditPatchSetNum);
+
+    if (isEdit) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: 'You cannot comment on an edit.'},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return false;
+    }
+    if (isEditBase) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'You cannot comment on the base patchset of an edit.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return false;
+    }
+    return true;
+  }
+
+  _createComment(
+    lineEl: Element,
+    lineNum?: LineNumber,
+    side?: Side,
+    range?: CommentRange
+  ) {
+    const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+    if (!contentEl) throw Error('content el not found for line el');
+    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: Element) {
+    return contentEl.querySelector('.thread-group');
+  }
+
+  /**
+   * Gets or creates a comment thread group for a specific line and side on a
+   * diff.
+   */
+  _getOrCreateThreadGroup(contentEl: Element, commentSide: Side) {
+    // 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.
+   */
+  _getPatchNumByLineAndContent(lineEl: Element, contentEl: Element) {
+    if (!this.patchRange) throw Error('patch range not set');
+    let patchNum = this.patchRange.patchNum;
+
+    if (
+      (lineEl.classList.contains(Side.LEFT) ||
+        contentEl.classList.contains('remove')) &&
+      this.patchRange.basePatchNum !== 'PARENT' &&
+      !isMergeParent(this.patchRange.basePatchNum)
+    ) {
+      patchNum = this.patchRange.basePatchNum;
+    }
+    return patchNum;
+  }
+
+  _getIsParentCommentByLineAndContent(lineEl: Element, contentEl: Element) {
+    if (!this.patchRange) throw Error('patch range not set');
+    return (
+      (lineEl.classList.contains(Side.LEFT) ||
+        contentEl.classList.contains('remove')) &&
+      (this.patchRange.basePatchNum === 'PARENT' ||
+        isMergeParent(this.patchRange.basePatchNum))
+    );
+  }
+
+  _getCommentSideByLineAndContent(lineEl: Element, contentEl: Element): Side {
+    let side = Side.RIGHT;
+    if (
+      lineEl.classList.contains(Side.LEFT) ||
+      contentEl.classList.contains('remove')
+    ) {
+      side = Side.LEFT;
+    }
+    return side;
+  }
+
+  _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
+    if (!this._prefsEqual(newPrefs, oldPrefs)) {
+      this._prefsChanged(newPrefs);
+    }
+  }
+
+  _prefsEqual(prefs1: DiffPreferencesInfo, prefs2: DiffPreferencesInfo) {
+    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) as DiffPreferencesInfoKey[];
+    const keys2 = Object.keys(prefs2) as DiffPreferencesInfoKey[];
+    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();
+  }
+
+  _lineWrappingObserver() {
+    this._prefsChanged(this.prefs);
+  }
+
+  _prefsChanged(prefs?: DiffPreferencesInfo) {
+    if (!prefs) return;
+
+    this.blame = null;
+
+    const lineLength =
+      this.path === COMMIT_MSG_PATH
+        ? COMMIT_MSG_LINE_LENGTH
+        : prefs.line_length;
+    const stylesToUpdate: {[key: string]: string} = {};
+
+    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?: DiffInfo) {
+    this._loading = true;
+    this._cleanup();
+    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() {
+    if (!this.prefs) {
+      this.dispatchEvent(
+        new CustomEvent('render', {bubbles: true, composed: true})
+      );
+      return;
+    }
+    if (
+      this.prefs.context === -1 &&
+      this._diffLength &&
+      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();
+    const bypassPrefs = this._getBypassPrefs(this.prefs);
+    this.$.diffBuilder.render(keyLocations, bypassPrefs).then(() => {
+      this.dispatchEvent(
+        new CustomEvent('render', {
+          bubbles: true,
+          composed: true,
+          detail: {contentRendered: true},
+        })
+      );
+    });
+  }
+
+  _handleRenderContent() {
+    this._loading = false;
+    this._unobserveIncrementalNodes();
+    this._incrementalNodeObserver = (dom(
+      this
+    ) as PolymerDomWrapper).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 = getSide(threadEl);
+        const lineEl = this.$.diffBuilder.getLineElByNumber(
+          lineNumString,
+          commentSide
+        );
+        // When the line the comment refers to does not exist, log an error
+        // but don't crash. This can happen e.g. if the API does not fully
+        // validate e.g. (robot) comments
+        if (!lineEl) {
+          console.error(
+            'thread attached to line ',
+            commentSide,
+            lineNumString,
+            ' which does not exist.'
+          );
+          continue;
+        }
+        const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+        if (!contentEl) continue;
+        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') as HTMLSlotElement;
+        const slotAtt = threadEl.getAttribute('slot');
+        if (slotAtt) slot.name = slotAtt;
+        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) as PolymerDomWrapper).unobserveNodes(
+        this._incrementalNodeObserver
+      );
+    }
+  }
+
+  _unobserveNodes() {
+    if (this._nodeObserver) {
+      (dom(this) as PolymerDomWrapper).unobserveNodes(this._nodeObserver);
+    }
+  }
+
+  /**
+   * Get the preferences object including the safety bypass context (if any).
+   */
+  _getBypassPrefs(prefs: DiffPreferencesInfo) {
+    if (this._safetyBypass !== null) {
+      return {...prefs, context: this._safetyBypass};
+    }
+    return prefs;
+  }
+
+  clearDiffContent() {
+    this._unobserveIncrementalNodes();
+    while (this.$.diffTable.hasChildNodes()) {
+      this.$.diffTable.removeChild(this.$.diffTable.lastChild!);
+    }
+  }
+
+  _computeDiffHeaderItems(
+    diffInfoRecord: PolymerDeepPropertyChange<DiffInfo, DiffInfo>
+  ) {
+    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'
+        )
+    );
+  }
+
+  _computeDiffHeaderHidden(items: string[]) {
+    return items.length === 0;
+  }
+
+  _handleFullBypass() {
+    this._safetyBypass = FULL_CONTEXT;
+    this._debounceRenderDiffTable();
+  }
+
+  _handleLimitedBypass() {
+    this._safetyBypass = LIMITED_CONTEXT;
+    this._debounceRenderDiffTable();
+  }
+
+  _computeWarningClass(showWarning?: boolean) {
+    return showWarning ? 'warn' : '';
+  }
+
+  _computeErrorClass(errorMessage?: string | null) {
+    return errorMessage ? 'showError' : '';
+  }
+
+  expandAllContext() {
+    this._handleFullBypass();
+  }
+
+  _computeNewlineWarning(warnLeft: boolean, warnRight: boolean) {
+    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 - '—'
+  }
+
+  _computeNewlineWarningClass(warning: boolean, loading: boolean) {
+    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.
+   */
+  getDiffLength(diff?: DiffInfo) {
+    if (!diff) return 0;
+    return diff.content.reduce((sum, sec) => {
+      if (sec.ab) {
+        return sum + sec.ab.length;
+      } else {
+        return (
+          sum + Math.max(sec.a ? sec.a.length : 0, sec.b ? sec.b.length : 0)
+        );
+      }
+    }, 0);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff': 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
deleted file mode 100644
index 55abd38..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
+++ /dev/null
@@ -1,454 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
new file mode 100644
index 0000000..ef2110b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -0,0 +1,635 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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;
+      table-layout: fixed;
+    }
+
+    /*
+      Context controls break up the table visually, so we set the right border
+      on individual sections to leave a gap for the divider.
+      */
+    .section {
+      border-right: 1px solid var(--border-color);
+    }
+    .section.contextControl.newStyle {
+      /*
+       * Divider inside this section must not have border; we set borders on
+       * the padding rows below.
+       */
+      border-right-width: 0;
+    }
+    /*
+     * Padding rows behind new style context controls. The diff is styled to be
+     * cut into two halves by the negative space of the divider on which the
+     * context control buttons are anchored.
+     */
+    .contextBackground {
+      border-right: 1px solid var(--border-color);
+    }
+    .contextBackground.above {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .contextBackground.below {
+      border-top: 1px solid var(--border-color);
+    }
+
+    .lineNumButton {
+      display: block;
+      width: 100%;
+      height: 100%;
+      background-color: var(--diff-blank-background-color);
+    }
+    td.lineNum {
+      vertical-align: top;
+    }
+
+    /*
+      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);
+    }
+    /*
+      The file line, which has no contentText, add some margin before the first
+      comment. We cannot add padding the container because we only want it if
+      there is at least one comment thread, and the slotting makes :empty not
+      work as expected.
+     */
+    .content.file slot:first-child::slotted(.comment-thread) {
+      display: block;
+      margin-top: var(--spacing-xs);
+    }
+    .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);
+    }
+
+    /* dueToMove */
+    .dueToMove .content.add .contentText,
+    .dueToMove .moveControls.movedIn .moveDescription,
+    .delta.total.dueToMove .content.add .contentText {
+      background-color: var(--light-moved-add-highlight-color);
+    }
+    .dueToMove .content.remove .contentText,
+    .dueToMove .moveControls.movedOut .moveDescription,
+    .delta.total.dueToMove .content.remove .contentText {
+      background-color: var(--light-remove-add-highlight-color);
+    }
+    .moveControls {
+      text-align: right;
+    }
+
+    /* 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';
+    }
+
+    /* Context controls */
+    .contextControl {
+      background-color: var(--diff-context-control-background-color);
+      border: 1px solid var(--diff-context-control-border-color);
+      color: var(--diff-context-control-color);
+      --divider-height: var(--spacing-s);
+      --divider-border: 1px;
+    }
+    .contextControl.newStyle {
+      background-color: transparent;
+      border: none;
+      /* Change to --diff-context-control-color once only new style exists. */
+      --diff-context-control-color: var(--default-button-text-color);
+    }
+    .contextControl:not(.newStyle) 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;
+    }
+
+    /*
+     * Padding rows behind new style context controls. Styled as a continuation
+     * of the line gutters and code area.
+     */
+    .contextBackground > .contextLineNum {
+      background-color: var(--diff-blank-background-color);
+    }
+    .contextBackground > td:not(.contextLineNum) {
+      background-color: var(--view-background-color);
+    }
+    .contextBackground {
+      /* 
+       * One line of background behind the context expanders which they can 
+       * render on top of, plus some padding.
+       */
+      height: calc(var(--line-height-normal) + var(--spacing-s));
+    }
+
+    .contextDivider {
+      height: var(--divider-height);
+      /* Create a positioning context. */
+      transform: translateX(0px);
+    }
+    .contextDivider.collapsed {
+      /* Hide divider gap, but still show child elements (expansion buttons). */
+      height: 0;
+    }
+    .dividerCell {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      justify-content: center;
+      position: absolute;
+      top: 0;
+      left: 0;
+    }
+    .contextControlButton {
+      background-color: var(--default-button-background-color);
+      font: var(--context-control-button-font, inherit);
+      /* All position is relative to container, so ignore sibling buttons. */
+      position: absolute;
+    }
+    .contextControlButton:first-child {
+      /* First button needs to claim width to display without text wrapping. */
+      position: relative;
+    }
+    .centeredButton {
+      /* Center over divider. */
+      top: 50%;
+      transform: translateY(-50%);
+      --gr-button: {
+        color: var(--diff-context-control-color);
+        border: solid var(--border-color);
+        border-width: 1px;
+        border-radius: var(--border-radius);
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+    }
+    .aboveBelowButtons {
+      display: flex;
+      flex-direction: column;
+      margin-left: var(--spacing-m);
+      position: relative;
+    }
+    .aboveBelowButtons:first-child {
+      margin-left: 0;
+    }
+    .aboveButton {
+      /* Display over preceding content / background placeholder. */
+      transform: translateY(-100%);
+      --gr-button: {
+        color: var(--diff-context-control-color);
+        border: solid var(--border-color);
+        border-width: 1px 1px 0 1px;
+        border-radius: var(--border-radius) var(--border-radius) 0 0;
+        padding: var(--spacing-xxs) var(--spacing-l);
+      }
+    }
+    .belowButton {
+      /* Display over following content / background placeholder. */
+      top: calc(100% + var(--divider-border));
+      --gr-button: {
+        color: var(--diff-context-control-color);
+        border: solid var(--border-color);
+        border-width: 0 1px 1px 1px;
+        border-radius: 0 0 var(--border-radius) var(--border-radius);
+        padding: var(--spacing-xxs) var(--spacing-l);
+      }
+    }
+    #diffTable:focus {
+      outline: none;
+    }
+
+    .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;
+    }
+    .special-char-indicator {
+      /* spacing so elements don't collide */
+      padding-right: var(--spacing-m);
+    }
+    .special-char-indicator:before {
+      color: var(--diff-tab-indicator-color);
+      content: '•';
+      position: absolute;
+    }
+    .special-char-warning {
+      /* spacing so elements don't collide */
+      padding-right: var(--spacing-m);
+    }
+    .special-char-warning:before {
+      color: var(--warning-foreground);
+      content: '!';
+      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]]"
+          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]]"
+          use-new-context-controls="[[useNewContextControls]]"
+        >
+          <table
+            id="diffTable"
+            class$="[[_diffTableClass]]"
+            role="presentation"
+            contenteditable$="[[isContentEditable]]"
+          ></table>
+
+          <template
+            is="dom-if"
+            if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
+          >
+            <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
deleted file mode 100644
index bb0366b..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ /dev/null
@@ -1,1167 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-diff</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="/components/web-component-tester/data/a11ySuite.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff></gr-diff>
-  </template>
-</test-fixture>
-
-<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';
-
-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(() => {
-      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);
-  });
-
-  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');
-      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);
-      });
-
-      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);
-      });
-    });
-
-    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 linenum', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('left');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      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));
-      });
-    });
-  });
-
-  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(() => {
-        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\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.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\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('getCursorStops returns [] when hidden and noAutoRender', () => {
-        element.noAutoRender = true;
-        setupDiff();
-        element.hidden = true;
-        assert.equal(element.getCursorStops().length, 0);
-      });
-
-      test('getCursorStops', () => {
-        setupDiff();
-        const ROWS = 48;
-        const FILE_ROW = 1;
-        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
-      });
-    });
-
-    test('adds .hiddenscroll', () => {
-      _setHiddenScroll(true);
-      element.displayLine = true;
-      assert.include(element.shadowRoot
-          .querySelector('.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({});
-          });
-      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();
-    });
-  });
-
-  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-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
new file mode 100644
index 0000000..36b3b8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -0,0 +1,1184 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import './gr-diff.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
+import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
+import {runA11yAudit} from '../../../test/a11y-test-utils.js';
+import '@polymer/paper-button/paper-button.js';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+
+const basicFixture = fixtureFromElement('gr-diff');
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    await runA11yAudit(basicFixture);
+  });
+});
+
+suite('gr-diff tests', () => {
+  let element;
+
+  const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+
+  setup(() => {
+
+  });
+
+  suite('selectionchange event handling', () => {
+    const emulateSelection = function() {
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+    };
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      sinon.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 = basicFixture.instantiate();
+    const cancelStub = sinon.stub(element.$.diffBuilder, 'cancel');
+    element.cancel();
+    assert.isTrue(cancelStub.calledOnce);
+  });
+
+  test('line limit with line_wrapping', () => {
+    element = basicFixture.instantiate();
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
+    flush();
+    assert.equal(getComputedStyleValue('--line-limit', element), '80ch');
+  });
+
+  test('line limit without line_wrapping', () => {
+    element = basicFixture.instantiate();
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
+    flush();
+    assert.isNotOk(getComputedStyleValue('--line-limit', element));
+  });
+
+  suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
+    let lineEl;
+    let contentEl;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      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);
+      });
+
+      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);
+      });
+    });
+
+    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 linenum', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('left');
+        assert.isTrue(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+      });
+
+      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));
+      });
+    });
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      const getLoggedInPromise = Promise.resolve(false);
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return getLoggedInPromise; },
+      });
+      element = basicFixture.instantiate();
+      return getLoggedInPromise;
+    });
+
+    test('toggleLeftDiff', () => {
+      element.toggleLeftDiff();
+      assert.isTrue(element.classList.contains('no-left'));
+      element.toggleLeftDiff();
+      assert.isFalse(element.classList.contains('no-left'));
+    });
+
+    test('addDraftAtLine', () => {
+      sinon.stub(element, '_selectLine');
+      const loggedInErrorSpy = sinon.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 = sinon.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(), {...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\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.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\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 = sinon.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 =
+          sinon.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 = sinon.stub(element, '_selectLine');
+      sinon.stub(element.$.diffBuilder, 'getLineElByChild')
+          .callsFake(() => 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();
+        flush();
+      };
+
+      test('getCursorStops returns [] when hidden and noAutoRender', () => {
+        element.noAutoRender = true;
+        setupDiff();
+        element.hidden = true;
+        assert.equal(element.getCursorStops().length, 0);
+      });
+
+      test('getCursorStops', () => {
+        setupDiff();
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
+      });
+    });
+
+    test('adds .hiddenscroll', () => {
+      _setHiddenScroll(true);
+      element.displayLine = true;
+      assert.include(element.shadowRoot
+          .querySelector('.diffContainer').className, 'hiddenscroll');
+    });
+  });
+
+  suite('logged in', () => {
+    let fakeLineEl;
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.loggedIn = true;
+      element.patchRange = {};
+
+      fakeLineEl = {
+        getAttribute: sinon.stub().returns(42),
+        classList: {
+          contains: sinon.stub().returns(true),
+        },
+      };
+    });
+
+    test('addDraftAtLine', () => {
+      sinon.stub(element, '_selectLine');
+      sinon.stub(element, '_createComment');
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(element._createComment
+          .calledWithExactly(fakeLineEl, 42));
+    });
+
+    test('addDraftAtLine on an edit', () => {
+      element.patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+      sinon.stub(element, '_selectLine');
+      sinon.stub(element, '_createComment');
+      const alertSpy = sinon.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 = SPECIAL_PATCH_SET_NUM.EDIT;
+      element.patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.PARENT;
+      sinon.stub(element, '_selectLine');
+      sinon.stub(element, '_createComment');
+      const alertSpy = sinon.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', () => {
+        sinon.stub(element, '_renderDiffTable');
+        element.prefs = {
+          ...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 = sinon.stub(element, '_renderDiffTable');
+        const newPrefs1 = {...MINIMAL_PREFS,
+          line_wrapping: true};
+        element.prefs = newPrefs1;
+        element.flushDebouncer('renderDiffTable');
+        assert.isTrue(element._renderDiffTable.called);
+        stub.reset();
+
+        const newPrefs2 = {...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', () => {
+        sinon.stub(element, '_renderDiffTable');
+        element.noRenderOnPrefsChange = true;
+        element.prefs = {
+          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
+        element.flushDebouncer('renderDiffTable');
+        assert.isFalse(element._renderDiffTable.called);
+      });
+    });
+  });
+
+  suite('diff header', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      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);
+      flush();
+
+      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 = basicFixture.instantiate();
+      renderStub = sinon.stub(element.$.diffBuilder, 'render').callsFake(
+          () => {
+            element.$.diffBuilder.dispatchEvent(
+                new CustomEvent('render', {bubbles: true, composed: true}));
+            return Promise.resolve({});
+          });
+      sinon.stub(element, 'getDiffLength').returns(10000);
+      element.diff = getMockDiffResponse();
+      element.noRenderOnPrefsChange = true;
+    });
+
+    test('large render w/ context = 10', done => {
+      element.prefs = {...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 = {...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 = {...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 = basicFixture.instantiate();
+    });
+
+    test('unsetting', () => {
+      element.blame = [];
+      const setBlameSpy = sinon.spy(element.$.diffBuilder, 'setBlame');
+      element.classList.add('showBlame');
+      element.blame = null;
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isFalse(element.classList.contains('showBlame'));
+    });
+
+    test('setting', () => {
+      element.blame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+      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 = basicFixture.instantiate();
+      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 = basicFixture.instantiate();
+      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 = basicFixture.instantiate();
+      element.prefs = {};
+      renderStub = sinon.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);
+      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');
+      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;
+    // binary can't be undefined, use false if not set
+    const binary = params.binary || false;
+    element = basicFixture.instantiate();
+    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,
+    };
+    element._renderDiffTable();
+    flush();
+  };
+
+  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();
+    element.diff = {...element.diff};
+    // immediately cleaned up
+    assert.equal(element.$.diffTable.innerHTML, '');
+    element._renderDiffTable();
+    flush();
+    // 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});
+      flush();
+      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';
+      flush();
+      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,
+          element.diff
+      ));
+    });
+
+    test('do not show the message for binary files', () => {
+      setupSampleDiff({content: [{skip: 100}], binary: true});
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+      ));
+    });
+
+    test('do not show the message if still loading', () => {
+      setupSampleDiff({content: [{skip: 100}]});
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ true,
+          element.prefs,
+          element._diffLength,
+          element.diff
+      ));
+    });
+
+    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,
+          element.diff
+      ));
+    });
+
+    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,
+          element.diff
+      ));
+    });
+  });
+
+  test('getDiffLength', () => {
+    const diff = getMockDiffResponse();
+    assert.equal(element.getDiffLength(diff), 52);
+  });
+
+  test('`render` event has contentRendered field in detail', done => {
+    element = basicFixture.instantiate();
+    element.prefs = {};
+    sinon.stub(element.$.diffBuilder, 'render')
+        .returns(Promise.resolve());
+    element.addEventListener('render', event => {
+      assert.isTrue(event.detail.contentRendered);
+      done();
+    });
+    element._renderDiffTable();
+  });
+
+  test('_prefsEqual', () => {
+    element = basicFixture.instantiate();
+    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'}));
+  });
+});
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
deleted file mode 100644
index 8bdc1a8..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ /dev/null
@@ -1,300 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
-
-/**
- * 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; }
-
-  static get is() { return 'gr-patch-range-select'; }
-
-  static get properties() {
-    return {
-      availablePatches: Array,
-      _baseDropdownContent: {
-        type: Object,
-        computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
-          '_sortedRevisions, changeComments, revisionInfo)',
-      },
-      _patchDropdownContent: {
-        type: Object,
-        computed: '_computePatchDropdownContent(availablePatches,' +
-          'basePatchNum, _sortedRevisions, changeComments)',
-      },
-      changeNum: String,
-      changeComments: Object,
-      /** @type {{ meta_a: !Array, meta_b: !Array}} */
-      filesWeblinks: Object,
-      patchNum: String,
-      basePatchNum: String,
-      revisions: Object,
-      revisionInfo: Object,
-      _sortedRevisions: Array,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateSortedRevisions(revisions.*)',
-    ];
-  }
-
-  _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;
-    }
-
-    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),
-      }));
-    }
-
-    dropdownContent.push({
-      text: isMerge ? 'Auto Merge' : 'Base',
-      value: 'PARENT',
-    });
-
-    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;
-  }
-
-  _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;
-    }
-
-    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}`);
-  }
-
-  _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));
-  }
-
-  /**
-   * 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; }
-
-    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);
-  }
-
-  _computePatchSetCommentsString(changeComments, patchNum) {
-    if (!changeComments) { return; }
-
-    const commentCount = changeComments.computeCommentCount({patchNum});
-    const commentString = GrCountStringFormatter.computePluralString(
-        commentCount, 'comment');
-
-    const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
-    const unresolvedString = GrCountStringFormatter.computeString(
-        unresolvedCount, 'unresolved');
-
-    if (!commentString.length && !unresolvedString.length) {
-      return '';
-    }
-
-    return ` (${commentString}` +
-        // Add a comma + space if both comments and unresolved
-        (commentString && unresolvedString ? ', ' : '') +
-        `${unresolvedString})`;
-  }
-
-  /**
-   * @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) : '';
-  }
-
-  /**
-   * @param {!Array} revisions
-   * @param {number|string} patchNum
-   */
-  _computePatchSetDate(revisions, patchNum) {
-    const rev = this.getRevisionByPatchNum(revisions, patchNum);
-    return rev ? rev.created : undefined;
-  }
-
-  /**
-   * 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;
-
-    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.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
new file mode 100644
index 0000000..2a9fe54
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -0,0 +1,469 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-select/gr-select';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-patch-range-select_html';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {appContext} from '../../../services/app-context';
+import {
+  computeLatestPatchNum,
+  findSortedIndex,
+  getParentIndex,
+  getRevisionByPatchNum,
+  isMergeParent,
+  patchNumEquals,
+  sortRevisions,
+  PatchSet,
+} from '../../../utils/patch-set-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  ParentPatchSetNum,
+  PatchSetNum,
+  RevisionInfo,
+  Timestamp,
+} from '../../../types/common';
+import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {
+  DropdownItem,
+  DropDownValueChangeEvent,
+  GrDropdownList,
+} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GeneratedWebLink} from '../../core/gr-navigation/gr-navigation';
+
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
+
+export interface PatchRangeChangeDetail {
+  patchNum?: PatchSetNum;
+  basePatchNum?: PatchSetNum;
+}
+
+export type PatchRangeChangeEvent = CustomEvent<PatchRangeChangeDetail>;
+
+export interface FilesWebLinks {
+  meta_a: GeneratedWebLink[];
+  meta_b: GeneratedWebLink[];
+}
+
+export interface GrPatchRangeSelect {
+  $: {
+    patchNumDropdown: GrDropdownList;
+  };
+}
+
+/**
+ * Fired when the patch range changes
+ *
+ * @event patch-range-change
+ *
+ * @property {string} patchNum
+ * @property {string} basePatchNum
+ * @extends PolymerElement
+ */
+@customElement('gr-patch-range-select')
+export class GrPatchRangeSelect extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array})
+  availablePatches?: PatchSet[];
+
+  @property({
+    type: Object,
+    computed:
+      '_computeBaseDropdownContent(availablePatches, patchNum,' +
+      '_sortedRevisions, changeComments, revisionInfo)',
+  })
+  _baseDropdownContent?: DropdownItem[];
+
+  @property({
+    type: Object,
+    computed:
+      '_computePatchDropdownContent(availablePatches,' +
+      'basePatchNum, _sortedRevisions, changeComments)',
+  })
+  _patchDropdownContent?: DropdownItem[];
+
+  @property({type: String})
+  changeNum?: string;
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
+  filesWeblinks?: FilesWebLinks;
+
+  @property({type: String})
+  patchNum?: PatchSetNum;
+
+  @property({type: String})
+  basePatchNum?: PatchSetNum;
+
+  @property({type: Object})
+  revisions?: RevisionInfo[];
+
+  @property({type: Object})
+  revisionInfo?: RevisionInfoClass;
+
+  @property({type: Array})
+  _sortedRevisions?: RevisionInfo[];
+
+  private readonly reporting: ReportingService = appContext.reportingService;
+
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
+  _getShaForPatch(patch: PatchSet) {
+    return patch.sha.substring(0, 10);
+  }
+
+  _computeBaseDropdownContent(
+    availablePatches?: PatchSet[],
+    patchNum?: PatchSetNum,
+    _sortedRevisions?: RevisionInfo[],
+    changeComments?: ChangeComments,
+    revisionInfo?: RevisionInfoClass
+  ): DropdownItem[] | undefined {
+    // Polymer 2: check for undefined
+    if (
+      availablePatches === undefined ||
+      patchNum === undefined ||
+      _sortedRevisions === undefined ||
+      changeComments === undefined ||
+      revisionInfo === undefined
+    ) {
+      return undefined;
+    }
+
+    const parentCounts = revisionInfo.getParentCountMap();
+    const currentParentCount = hasOwnProperty(parentCounts, patchNum)
+      ? parentCounts[patchNum as number]
+      : 1;
+    const maxParents = revisionInfo.getMaxParents();
+    const isMerge = currentParentCount > 1;
+
+    const dropdownContent: DropdownItem[] = [];
+    for (const basePatch of availablePatches) {
+      const basePatchNum = basePatch.num;
+      const entry: DropdownItem = this._createDropdownEntry(
+        basePatchNum,
+        'Patchset ',
+        _sortedRevisions,
+        changeComments,
+        this._getShaForPatch(basePatch)
+      );
+      dropdownContent.push({
+        ...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({
+        disabled: idx >= currentParentCount,
+        triggerText: `Parent ${idx + 1}`,
+        text: `Parent ${idx + 1}`,
+        mobileText: `Parent ${idx + 1}`,
+        value: -(idx + 1),
+      });
+    }
+
+    return dropdownContent;
+  }
+
+  _computeMobileText(
+    patchNum: PatchSetNum,
+    changeComments: ChangeComments,
+    revisions: RevisionInfo[]
+  ) {
+    return (
+      `${patchNum}` +
+      `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+      `${this._computePatchSetDescription(revisions, patchNum, true)}`
+    );
+  }
+
+  _computePatchDropdownContent(
+    availablePatches?: PatchSet[],
+    basePatchNum?: PatchSetNum,
+    _sortedRevisions?: RevisionInfo[],
+    changeComments?: ChangeComments
+  ): DropdownItem[] | undefined {
+    // Polymer 2: check for undefined
+    if (
+      availablePatches === undefined ||
+      basePatchNum === undefined ||
+      _sortedRevisions === undefined ||
+      changeComments === undefined
+    ) {
+      return undefined;
+    }
+
+    const dropdownContent: DropdownItem[] = [];
+    for (const patch of availablePatches) {
+      const patchNum = patch.num;
+      const entry = this._createDropdownEntry(
+        patchNum,
+        patchNum === 'edit' ? '' : 'Patchset ',
+        _sortedRevisions,
+        changeComments,
+        this._getShaForPatch(patch)
+      );
+      dropdownContent.push({
+        ...entry,
+        disabled: this._computeRightDisabled(
+          basePatchNum,
+          patchNum,
+          _sortedRevisions
+        ),
+      });
+    }
+    return dropdownContent;
+  }
+
+  _computeText(
+    patchNum: PatchSetNum,
+    prefix: string,
+    changeComments: ChangeComments,
+    sha: string
+  ) {
+    return (
+      `${prefix}${patchNum}` +
+      `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+      ` | ${sha}`
+    );
+  }
+
+  _createDropdownEntry(
+    patchNum: PatchSetNum,
+    prefix: string,
+    sortedRevisions: RevisionInfo[],
+    changeComments: ChangeComments,
+    sha: string
+  ) {
+    const entry: DropdownItem = {
+      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;
+  }
+
+  @observe('revisions.*')
+  _updateSortedRevisions(
+    revisionsRecord: PolymerDeepPropertyChange<RevisionInfo[], RevisionInfo[]>
+  ) {
+    const revisions = revisionsRecord.base;
+    if (!revisions) return;
+    this._sortedRevisions = 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 basePatchNum The possible base patch num.
+   * @param patchNum The current selected patch num.
+   */
+  _computeLeftDisabled(
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    sortedRevisions: RevisionInfo[]
+  ): boolean {
+    return (
+      findSortedIndex(basePatchNum, sortedRevisions) <=
+      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 current basePatchNum is a parent index, then only patches that have
+   * at least that many parents are valid.
+   *
+   * @param basePatchNum The current selected base patch num.
+   * @param patchNum The possible patch num.
+   */
+  _computeRightDisabled(
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    sortedRevisions: RevisionInfo[]
+  ): boolean {
+    if (patchNumEquals(basePatchNum, ParentPatchSetNum)) {
+      return false;
+    }
+
+    if (isMergeParent(basePatchNum)) {
+      if (!this.revisionInfo) {
+        return true;
+      }
+      // Note: parent indices use 1-offset.
+      return (
+        this.revisionInfo.getParentCount(patchNum) <
+        getParentIndex(basePatchNum)
+      );
+    }
+
+    return (
+      findSortedIndex(basePatchNum, sortedRevisions) <=
+      findSortedIndex(patchNum, sortedRevisions)
+    );
+  }
+
+  _computePatchSetCommentsString(
+    changeComments: ChangeComments,
+    patchNum: PatchSetNum
+  ) {
+    if (!changeComments) {
+      return;
+    }
+
+    const commentThreadCount = changeComments.computeCommentThreadCount({
+      patchNum,
+    });
+    const commentThreadString = GrCountStringFormatter.computePluralString(
+      commentThreadCount,
+      'comment'
+    );
+
+    const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
+    const unresolvedString = GrCountStringFormatter.computeString(
+      unresolvedCount,
+      'unresolved'
+    );
+
+    if (!commentThreadString.length && !unresolvedString.length) {
+      return '';
+    }
+
+    return (
+      ` (${commentThreadString}` +
+      // Add a comma + space if both comment threads and unresolved
+      (commentThreadString && unresolvedString ? ', ' : '') +
+      `${unresolvedString})`
+    );
+  }
+
+  _computePatchSetDescription(
+    revisions: RevisionInfo[],
+    patchNum: PatchSetNum,
+    addFrontSpace?: boolean
+  ) {
+    const rev = getRevisionByPatchNum(revisions, patchNum);
+    return rev?.description
+      ? (addFrontSpace ? ' ' : '') +
+          rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
+      : '';
+  }
+
+  _computePatchSetDate(
+    revisions: RevisionInfo[],
+    patchNum: PatchSetNum
+  ): Timestamp | undefined {
+    const rev = getRevisionByPatchNum(revisions, patchNum);
+    return rev ? rev.created : undefined;
+  }
+
+  /**
+   * Catches value-change events from the patchset dropdowns and determines
+   * whether or not a patch change event should be fired.
+   */
+  _handlePatchChange(e: DropDownValueChangeEvent) {
+    const detail: PatchRangeChangeDetail = {
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
+    };
+    const target = (dom(e) as EventApi).localTarget;
+    const patchSetValue = e.detail.value as PatchSetNum;
+    const latestPatchNum = computeLatestPatchNum(this.availablePatches);
+    if (target === this.$.patchNumDropdown) {
+      if (detail.patchNum === e.detail.value) return;
+      this.reporting.reportInteraction('right-patchset-changed', {
+        previous: detail.patchNum,
+        current: e.detail.value,
+        latest: latestPatchNum,
+        commentCount: this.changeComments?.computeCommentThreadCount({
+          patchNum: e.detail.value as PatchSetNum,
+        }),
+      });
+      detail.patchNum = patchSetValue;
+    } else {
+      if (patchNumEquals(detail.basePatchNum, patchSetValue)) return;
+      this.reporting.reportInteraction('left-patchset-changed', {
+        previous: detail.basePatchNum,
+        current: e.detail.value,
+        commentCount: this.changeComments?.computeCommentThreadCount({
+          patchNum: patchSetValue,
+        }),
+      });
+      detail.basePatchNum = patchSetValue;
+    }
+
+    this.dispatchEvent(
+      new CustomEvent('patch-range-change', {detail, bubbles: false})
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-patch-range-select': 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
deleted file mode 100644
index 1d4b440..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
new file mode 100644
index 0000000..52465b3
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
@@ -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';
+
+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;
+        }
+      }
+    }
+  </style>
+  <h3 class="assistive-tech-only">Patchset Range Selection</h3>
+  <span class="patchRange" aria-label="patch range starts with">
+    <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 aria-hidden="true" class="arrow">→</span>
+  <span class="patchRange" aria-label="patch range ends with">
+    <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
deleted file mode 100644
index 63e6fc6..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ /dev/null
@@ -1,429 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-patch-range-select</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/page/page.js"></script>
-
-<dom-module id="comment-api-mock">
-  <template>
-    <gr-patch-range-select id="patchRange" auto
-        change-comments="[[_changeComments]]"></gr-patch-range-select>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-  </template>
-  </dom-module>
-
-<test-fixture id="basic">
-  <template>
-    <comment-api-mock></comment-api-mock>
-  </template>
-</test-fixture>
-
-<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 RevisionInfo({revisions: revisionObj});
-  }
-
-  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();
-  });
-
-  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-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
new file mode 100644
index 0000000..15841d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -0,0 +1,414 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-comment-api/gr-comment-api.js';
+import '../../shared/revision-info/revision-info.js';
+import './gr-patch-range-select.js';
+import '../../../test/mocks/comment-api.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
+import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
+
+const commentApiMockElement = createCommentApiMockWithTemplateElement(
+    'gr-patch-range-select-comment-api-mock', html`
+    <gr-patch-range-select id="patchRange" auto
+        change-comments="[[_changeComments]]"></gr-patch-range-select>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
+`);
+
+const basicFixture = fixtureFromElement(commentApiMockElement.is);
+
+suite('gr-patch-range-select tests', () => {
+  let element;
+
+  let commentApiWrapper;
+
+  function getInfo(revisions) {
+    const revisionObj = {};
+    for (let i = 0; i < revisions.length; i++) {
+      revisionObj[i] = revisions[i];
+    }
+    return new RevisionInfo({revisions: revisionObj});
+  }
+
+  setup(() => {
+    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 = basicFixture.instantiate();
+    element = commentApiWrapper.$.patchRange;
+
+    // Stub methods on the changeComments object after changeComments has
+    // been initialized.
+    return commentApiWrapper.loadComments();
+  });
+
+  test('enabled/disabled options', () => {
+    const patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 3,
+    };
+    const sortedRevisions = [
+      {_number: 3},
+      {_number: SPECIAL_PATCH_SET_NUM.EDIT, 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 = SPECIAL_PATCH_SET_NUM.EDIT;
+    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,
+        SPECIAL_PATCH_SET_NUM.EDIT, 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: SPECIAL_PATCH_SET_NUM.EDIT, 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';
+    flush();
+
+    sinon.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';
+        flush();
+
+        // Should be recomputed for each available patch
+        sinon.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';
+    flush();
+
+    // Should be recomputed for each available patch
+    sinon.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';
+    flush();
+
+    // Should be recomputed for each available patch
+    sinon.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: SPECIAL_PATCH_SET_NUM.EDIT, 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',
+        },
+      ],
+    };
+    flush();
+    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.
+    const 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: [],
+    };
+    element.changeComments = new ChangeComments(comments, {}, {}, 123);
+
+    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 = sinon.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'});
+  });
+});
+
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
deleted file mode 100644
index c3b6b87..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ /dev/null
@@ -1,242 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.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-ranged-comment-layer_html.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-
-// Polymer 1 adds # before array's key, while Polymer 2 doesn't
-const HOVER_PATH_PATTERN = /^(commentRanges\.\#?\d+)\.hovering$/;
-
-const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
-const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
-
-/** @extends Polymer.Element */
-class GrRangedCommentLayer extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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,
-        value() { return []; },
-      },
-      _rangesMap: {
-        type: Object,
-        value() { return {left: {}, right: {}}; },
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handleCommentRangesChange(commentRanges.*)',
-    ];
-  }
-
-  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'));
-    }
-    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 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, skipLayerUpdate: true,
-        operation: (forLine, start, end, hovering) => {
-          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, operation: (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,
-            operation: (forLine, start, end, hovering) => {
-              forLine.push({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;
-
-          // 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));
-  }
-}
-
-customElements.define(GrRangedCommentLayer.is, GrRangedCommentLayer);
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
new file mode 100644
index 0000000..bb3733f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -0,0 +1,307 @@
+/**
+ * @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 {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-ranged-comment-layer_html';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {strToClassName} from '../../../utils/dom-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {Side} from '../../../constants/constants';
+import {
+  PolymerDeepPropertyChange,
+  PolymerSpliceChange,
+} from '@polymer/polymer/interfaces';
+import {CommentRange} from '../../../types/common';
+import {DiffLayer, DiffLayerListener} from '../../../types/types';
+
+/**
+ * Enhanced CommentRange by UI state. Interface for incoming ranges set from the
+ * outside.
+ *
+ * TODO(TS): Unify with what is used in gr-diff when these objects are created.
+ */
+export interface CommentRangeLayer {
+  side: Side;
+  range: CommentRange;
+  hovering: boolean;
+  rootId: string;
+}
+
+/**
+ * This class breaks down all comment ranges into individual line segment
+ * highlights.
+ */
+interface CommentRangeLineLayer {
+  hovering: boolean;
+  rootId: string;
+  start: number;
+  end: number;
+}
+
+type LinesMap = {
+  [line in number]: CommentRangeLineLayer[];
+};
+
+type RangesMap = {
+  [side in Side]: LinesMap;
+};
+
+// Polymer 1 adds # before array's key, while Polymer 2 doesn't
+const HOVER_PATH_PATTERN = /^(commentRanges\.#?\d+)\.hovering$/;
+
+const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
+const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
+
+@customElement('gr-ranged-comment-layer')
+export class GrRangedCommentLayer
+  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  implements DiffLayer {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * 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
+   */
+
+  @property({type: Array})
+  commentRanges: CommentRangeLayer[] = [];
+
+  @property({type: Array})
+  _listeners: DiffLayerListener[] = [];
+
+  @property({type: Object})
+  _rangesMap: RangesMap = {left: {}, right: {}};
+
+  get styleModuleName() {
+    return 'gr-ranged-comment-styles';
+  }
+
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param el The DIV.contentText element to apply the annotation to.
+   */
+  annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+    let ranges: CommentRangeLineLayer[] = [];
+    if (
+      line.type === GrDiffLineType.REMOVE ||
+      (line.type === GrDiffLineType.BOTH &&
+        el.getAttribute('data-side') !== 'right')
+    ) {
+      ranges = ranges.concat(this._getRangesForLine(line, Side.LEFT));
+    }
+    if (
+      line.type === GrDiffLineType.ADD ||
+      (line.type === GrDiffLineType.BOTH &&
+        el.getAttribute('data-side') !== 'left')
+    ) {
+      ranges = ranges.concat(this._getRangesForLine(line, Side.RIGHT));
+    }
+
+    for (const range of ranges) {
+      GrAnnotation.annotateElement(
+        el,
+        range.start,
+        range.end - range.start,
+        (range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT) +
+          ` ${strToClassName(range.rootId)}`
+      );
+    }
+  }
+
+  /**
+   * Register a listener for layer updates.
+   */
+  addListener(listener: DiffLayerListener) {
+    this._listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this._listeners = this._listeners.filter(f => f !== listener);
+  }
+
+  /**
+   * Notify Layer listeners of changes to annotations.
+   */
+  _notifyUpdateRange(start: number, end: number, side: 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.
+   */
+  @observe('commentRanges.*')
+  _handleCommentRangesChange(
+    record: PolymerDeepPropertyChange<
+      CommentRangeLayer[],
+      PolymerSpliceChange<CommentRangeLayer[]>
+    >
+  ) {
+    if (!record) return;
+
+    // If the entire set of comments was changed.
+    if (record.path === 'commentRanges') {
+      const value = record.value as CommentRangeLayer[];
+      this._rangesMap = {left: {}, right: {}};
+      for (const {side, range, rootId, hovering} of value) {
+        this._updateRangesMap({
+          side,
+          range,
+          hovering,
+          operation: (forLine, start, end, hovering) => {
+            forLine.push({start, end, hovering, rootId});
+          },
+        });
+      }
+    }
+
+    // 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, rootId} = this.get(match[1]);
+
+      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;
+          forLine[index].rootId = rootId;
+        },
+      });
+    }
+
+    // If comments were spliced in or out.
+    if (record.path === 'commentRanges.splices') {
+      const value = record.value as PolymerSpliceChange<CommentRangeLayer[]>;
+      for (const indexSplice of value.indexSplices) {
+        const removed = indexSplice.removed;
+        for (const {side, range, hovering, rootId} of removed) {
+          this._updateRangesMap({
+            side,
+            range,
+            hovering,
+            operation: (forLine, start, end) => {
+              const index = forLine.findIndex(
+                lineRange =>
+                  lineRange.start === start &&
+                  lineRange.end === end &&
+                  rootId === lineRange.rootId
+              );
+              forLine.splice(index, 1);
+            },
+          });
+        }
+        const added = indexSplice.object.slice(
+          indexSplice.index,
+          indexSplice.index + indexSplice.addedCount
+        );
+        for (const {side, range, hovering, rootId} of added) {
+          this._updateRangesMap({
+            side,
+            range,
+            hovering,
+            operation: (forLine, start, end, hovering) => {
+              forLine.push({start, end, hovering, rootId});
+            },
+          });
+        }
+      }
+    }
+  }
+
+  _updateRangesMap(options: {
+    side: Side;
+    range: CommentRange;
+    hovering: boolean;
+    operation: (
+      forLine: CommentRangeLineLayer[],
+      start: number,
+      end: number,
+      hovering: boolean
+    ) => void;
+    skipLayerUpdate?: boolean;
+  }) {
+    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: GrDiffLine, side: Side) {
+    const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
+    const ranges: CommentRangeLineLayer[] =
+      this.get(['_rangesMap', side, lineNum]) || [];
+    return (
+      ranges
+        .map(range => {
+          // Make a copy, so that the normalization below does not mess with
+          // our map.
+          range = {...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},
+              })
+            );
+          }
+
+          return range;
+        })
+        // Sort the ranges so that hovering highlights are on top.
+        .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0))
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-ranged-comment-layer': 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
deleted file mode 100644
index 3ed33d1..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
new file mode 100644
index 0000000..1489006
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 37d1707..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ /dev/null
@@ -1,342 +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">
-<meta charset="utf-8">
-<title>gr-ranged-comment-layer</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-ranged-comment-layer></gr-ranged-comment-layer>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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,';
-    });
-
-    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');
-    });
-  });
-
-  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-layer/gr-ranged-comment-layer_test.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
new file mode 100644
index 0000000..441d585
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
@@ -0,0 +1,321 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-diff/gr-diff-line.js';
+import './gr-ranged-comment-layer.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
+
+const basicFixture = fixtureFromElement('gr-ranged-comment-layer');
+
+suite('gr-ranged-comment-layer', () => {
+  let element;
+
+  setup(() => {
+    const initialCommentRanges = [
+      {
+        side: 'left',
+        range: {
+          end_character: 9,
+          end_line: 39,
+          start_character: 6,
+          start_line: 36,
+        },
+        rootId: 'a',
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 22,
+          end_line: 12,
+          start_character: 10,
+          start_line: 10,
+        },
+        rootId: 'b',
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 15,
+          end_line: 100,
+          start_character: 5,
+          start_line: 100,
+        },
+        rootId: 'c',
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 2,
+          end_line: 55,
+          start_character: 32,
+          start_line: 55,
+        },
+        rootId: 'd',
+      },
+    ];
+
+    element = basicFixture.instantiate();
+    element.commentRanges = initialCommentRanges;
+  });
+
+  suite('annotate', () => {
+    let el;
+    let line;
+    let annotateElementStub;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      el = document.createElement('div');
+      el.setAttribute('data-side', 'left');
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
+    });
+
+    test('type=Remove no-comment', () => {
+      line.type = GrDiffLineType.REMOVE;
+      line.beforeNumber = 40;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('type=Remove has-comment', () => {
+      line.type = GrDiffLineType.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 generated_a');
+    });
+
+    test('type=Remove has-comment hovering', () => {
+      line.type = GrDiffLineType.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 generated_a'
+      );
+    });
+
+    test('type=Both has-comment', () => {
+      line.type = GrDiffLineType.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 generated_a');
+    });
+
+    test('type=Both has-comment off side', () => {
+      line.type = GrDiffLineType.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 = GrDiffLineType.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 generated_b');
+    });
+  });
+
+  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 = sinon.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 = sinon.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);
+  });
+});
+
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
deleted file mode 100644
index 49ed980..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js
+++ /dev/null
@@ -1,41 +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.
- */
-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-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
new file mode 100644
index 0000000..70ee196
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -0,0 +1,46 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
deleted file mode 100644
index f16db3b..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrSelectionActionBox extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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; },
-      },
-      positionBelow: Boolean,
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-
-    // See https://crbug.com/gerrit/4767
-    this.addEventListener('mousedown',
-        e => this._handleMouseDown(e));
-  }
-
-  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';
-  }
-
-  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';
-  }
-
-  _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;
-  }
-
-  _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,
-    }));
-  }
-}
-
-customElements.define(GrSelectionActionBox.is, GrSelectionActionBox);
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
new file mode 100644
index 0000000..d702fb1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -0,0 +1,132 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {GrTooltip} from '../../shared/gr-tooltip/gr-tooltip';
+import {customElement, property} from '@polymer/decorators';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-selection-action-box_html';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-selection-action-box': GrSelectionActionBox;
+  }
+}
+
+export interface GrSelectionActionBox {
+  $: {
+    tooltip: GrTooltip;
+  };
+}
+
+@customElement('gr-selection-action-box')
+export class GrSelectionActionBox extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the comment creation action was taken (click).
+   *
+   * @event create-comment-requested
+   */
+
+  @property({type: Object})
+  keyEventTarget: Record<string, any> = document.body;
+
+  @property({type: Boolean})
+  positionBelow = false;
+
+  /** @override */
+  created() {
+    super.created();
+
+    // See https://crbug.com/gerrit/4767
+    this.addEventListener('mousedown', e => this._handleMouseDown(e));
+  }
+
+  placeAbove(el: Text | Element | Range) {
+    flush();
+    const rect = this._getTargetBoundingRect(el);
+    const boxRect = this.$.tooltip.getBoundingClientRect();
+    const parentRect = this._getParentBoundingClientRect();
+    if (parentRect === null) {
+      return;
+    }
+    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: Text | Element | Range) {
+    flush();
+    const rect = this._getTargetBoundingRect(el);
+    const boxRect = this.$.tooltip.getBoundingClientRect();
+    const parentRect = this._getParentBoundingClientRect();
+    if (parentRect === null) {
+      return;
+    }
+    this.style.top = `${rect.top - parentRect.top + boxRect.height - 6}px`;
+    this.style.left = `${
+      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
+    }px`;
+  }
+
+  private _getParentBoundingClientRect() {
+    // With native shadow DOM, the parent is the shadow root, not the gr-diff
+    // element
+    if (this.parentElement) {
+      return this.parentElement.getBoundingClientRect();
+    }
+    if (this.parentNode !== null) {
+      return (this.parentNode as ShadowRoot).host.getBoundingClientRect();
+    }
+    return null;
+  }
+
+  private _getTargetBoundingRect(el: Text | Element | Range) {
+    let rect;
+    if (el instanceof Text) {
+      const range = document.createRange();
+      range.selectNode(el);
+      rect = range.getBoundingClientRect();
+      range.detach();
+    } else {
+      rect = el.getBoundingClientRect();
+    }
+    return rect;
+  }
+
+  private _handleMouseDown(e: MouseEvent) {
+    if (e.button !== 0) {
+      return;
+    } // 0 = main button
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('create-comment-requested', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+}
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
deleted file mode 100644
index e7795b9..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
new file mode 100644
index 0000000..24d63b3
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
@@ -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';
+
+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
deleted file mode 100644
index ff6fba7..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ /dev/null
@@ -1,135 +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">
-<meta charset="utf-8">
-<title>gr-selection-action-box</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>
-    <div>
-      <gr-selection-action-box></gr-selection-action-box>
-      <div class="target">some text</div>
-    </div>
-  </template>
-</test-fixture>
-
-<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(() => {
-      e = {
-        button: 0,
-        preventDefault: sandbox.stub(),
-        stopPropagation: sandbox.stub(),
-      };
-    });
-
-    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'
-      );
-    });
-
-    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-selection-action-box/gr-selection-action-box_test.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
new file mode 100644
index 0000000..81cf0d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-selection-action-box.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+  <div>
+    <gr-selection-action-box></gr-selection-action-box>
+    <div class="target">some text</div>
+  </div>
+`);
+
+suite('gr-selection-action-box', () => {
+  let container;
+  let element;
+
+  setup(() => {
+    container = basicFixture.instantiate();
+    element = container.querySelector('gr-selection-action-box');
+
+    sinon.stub(element, 'dispatchEvent');
+  });
+
+  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(() => {
+      e = {
+        button: 0,
+        preventDefault: sinon.stub(),
+        stopPropagation: sinon.stub(),
+      };
+    });
+
+    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'
+      );
+    });
+
+    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');
+      sinon.stub(container, 'getBoundingClientRect').returns(
+          {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
+      sinon.stub(element, '_getTargetBoundingRect').returns(
+          {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
+      sinon.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', () => {
+      sinon.spy(document, 'createRange');
+      element._getTargetBoundingRect.restore();
+      sinon.spy(element, '_getTargetBoundingRect');
+      element.placeAbove(target.firstChild);
+      assert.isTrue(document.createRange.called);
+    });
+  });
+});
+
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
deleted file mode 100644
index f1e930f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ /dev/null
@@ -1,556 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 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 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 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;
-
-/** @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',
-      },
-      enabled: {
-        type: Boolean,
-        value: true,
-      },
-      _baseRanges: {
-        type: Array,
-        value() { return []; },
-      },
-      _revisionRanges: {
-        type: Array,
-        value() { return []; },
-      },
-      _baseLanguage: String,
-      _revisionLanguage: String,
-      _listeners: {
-        type: Array,
-        value() { return []; },
-      },
-      /** @type {?number} */
-      _processHandle: Number,
-      /**
-       * The promise last returned from `process()` while the asynchronous
-       * processing is running - `null` otherwise. Provides a `cancel()`
-       * method that rejects it with `{isCancelled: true}`.
-       *
-       * @type {?Object}
-       */
-      _processPromise: {
-        type: Object,
-        value: null,
-      },
-      _hljs: Object,
-    };
-  }
-
-  addListener(fn) {
-    this.push('_listeners', fn);
-  }
-
-  removeListener(fn) {
-    this._listeners = this._listeners.filter(f => f != fn);
-  }
-
-  /**
-   * Annotation layer method to add syntax annotations to the given element
-   * 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';
-    }
-
-    // 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);
-    }
-  }
-
-  _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();
-
-    // Discard existing ranges.
-    this._baseRanges = [];
-    this._revisionRanges = [];
-
-    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();
-    }
-
-    const state = {
-      sectionIndex: 0,
-      lineIndex: 0,
-      baseContext: undefined,
-      revisionContext: undefined,
-      lineNums: {left: 1, right: 1},
-      lastNotify: {left: 1, right: 1},
-    };
-
-    const rangesCache = new Map();
-
-    this._processPromise = util.makeCancelable(this._loadHLJS()
-        .then(() => new Promise(resolve => {
-          const nextStep = () => {
-            this._processHandle = null;
-            this._processNextLine(state, rangesCache);
-
-            // Move to the next line in the section.
-            state.lineIndex++;
-
-            // If the section has been exhausted, move to the next one.
-            if (this._isSectionDone(state)) {
-              state.lineIndex = 0;
-              state.sectionIndex++;
-            }
-
-            // If all sections have been exhausted, finish.
-            if (state.sectionIndex >= this.diff.content.length) {
-              resolve();
-              this._notify(state);
-              return;
-            }
-
-            if (state.lineIndex % 100 === 0) {
-              this._notify(state);
-              this._processHandle = this.async(nextStep, ASYNC_DELAY);
-            } else {
-              nextStep.call(this);
-            }
-          };
-
-          this._processHandle = this.async(nextStep, 1);
-        })));
-    return this._processPromise
-        .finally(() => { this._processPromise = null; });
-  }
-
-  /**
-   * Cancel any asynchronous syntax processing jobs.
-   */
-  _cancel() {
-    if (this._processHandle != null) {
-      this.cancelAsync(this._processHandle);
-      this._processHandle = null;
-    }
-    if (this._processPromise) {
-      this._processPromise.cancel();
-    }
-  }
-
-  _diffChanged() {
-    this._cancel();
-    this._baseRanges = [];
-    this._revisionRanges = [];
-  }
-
-  /**
-   * 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,
-          });
-        }
-        if (node.children.length) {
-          result = result.concat(this._rangesFromElement(node, offset));
-        }
-      }
-      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, 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];
-      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++;
-      }
-    }
-
-    // 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, 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, 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;
-    }
-
-    /**
-     * 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}
-     */
-    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, '"\\\\"');
-    }
-
-    return line;
-  }
-
-  /**
-   * 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.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
new file mode 100644
index 0000000..5b76283
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -0,0 +1,612 @@
+/**
+ * @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 '../../shared/gr-lib-loader/gr-lib-loader';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-syntax-layer_html';
+import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {CancelablePromise, util} from '../../../scripts/util';
+import {customElement, property} from '@polymer/decorators';
+import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
+import {DiffFileMetaInfo, DiffInfo} from '../../../types/common';
+import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
+import {Side} from '../../../constants/constants';
+
+const LANGUAGE_MAP = new Map<string, string>([
+  ['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/tsx', '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 CLASS_SAFELIST = new Set<string>([
+  'gr-diff gr-syntax gr-syntax-attr',
+  'gr-diff gr-syntax gr-syntax-attribute',
+  'gr-diff gr-syntax gr-syntax-built_in',
+  'gr-diff gr-syntax gr-syntax-comment',
+  'gr-diff gr-syntax gr-syntax-doctag',
+  'gr-diff gr-syntax gr-syntax-function',
+  'gr-diff gr-syntax gr-syntax-keyword',
+  'gr-diff gr-syntax gr-syntax-link',
+  'gr-diff gr-syntax gr-syntax-literal',
+  'gr-diff gr-syntax gr-syntax-meta',
+  'gr-diff gr-syntax gr-syntax-meta-keyword',
+  'gr-diff gr-syntax gr-syntax-name',
+  'gr-diff gr-syntax gr-syntax-number',
+  'gr-diff gr-syntax gr-syntax-params',
+  'gr-diff gr-syntax gr-syntax-property',
+  'gr-diff gr-syntax gr-syntax-regexp',
+  'gr-diff gr-syntax gr-syntax-selector-attr',
+  'gr-diff gr-syntax gr-syntax-selector-class',
+  'gr-diff gr-syntax gr-syntax-selector-id',
+  'gr-diff gr-syntax gr-syntax-selector-pseudo',
+  'gr-diff gr-syntax gr-syntax-selector-tag',
+  'gr-diff gr-syntax gr-syntax-string',
+  'gr-diff gr-syntax gr-syntax-tag',
+  'gr-diff gr-syntax gr-syntax-template-tag',
+  'gr-diff gr-syntax gr-syntax-template-variable',
+  'gr-diff gr-syntax gr-syntax-title',
+  'gr-diff gr-syntax gr-syntax-type',
+  'gr-diff gr-syntax gr-syntax-variable',
+]);
+
+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;
+
+interface SyntaxLayerRange {
+  start: number;
+  length: number;
+  className: string;
+}
+
+interface SyntaxLayerState {
+  sectionIndex: number;
+  lineIndex: number;
+  baseContext: unknown;
+  revisionContext: unknown;
+  lineNums: {left: number; right: number};
+  lastNotify: {left: number; right: number};
+}
+
+export interface GrSyntaxLayer {
+  $: {
+    libLoader: GrLibLoader;
+  };
+}
+
+@customElement('gr-syntax-layer')
+export class GrSyntaxLayer
+  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  implements DiffLayer {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object, observer: '_diffChanged'})
+  diff?: DiffInfo;
+
+  @property({type: Boolean})
+  enabled = true;
+
+  @property({type: Array})
+  _baseRanges: SyntaxLayerRange[][] = [];
+
+  @property({type: Array})
+  _revisionRanges: SyntaxLayerRange[][] = [];
+
+  @property({type: String})
+  _baseLanguage?: string;
+
+  @property({type: String})
+  _revisionLanguage?: string;
+
+  @property({type: Array})
+  _listeners: DiffLayerListener[] = [];
+
+  @property({type: Number})
+  _processHandle: number | null = null;
+
+  @property({type: Object})
+  _processPromise: CancelablePromise<unknown> | null = null;
+
+  @property({type: Object})
+  _hljs?: HighlightJS;
+
+  addListener(listener: DiffLayerListener) {
+    this.push('_listeners', listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this._listeners = this._listeners.filter(f => f !== listener);
+  }
+
+  /**
+   * Annotation layer method to add syntax annotations to the given element
+   * for the given line.
+   */
+  annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+    if (!this.enabled) return;
+    if (line.beforeNumber === FILE) return;
+    if (line.afterNumber === FILE) return;
+
+    // Determine the side.
+    let side;
+    if (
+      line.type === GrDiffLineType.REMOVE ||
+      (line.type === GrDiffLineType.BOTH &&
+        el.getAttribute('data-side') !== 'right')
+    ) {
+      side = 'left';
+    } else if (
+      line.type === GrDiffLineType.ADD ||
+      el.getAttribute('data-side') !== 'left'
+    ) {
+      side = 'right';
+    }
+
+    // Find the relevant syntax ranges, if any.
+    let ranges: SyntaxLayerRange[] = [];
+    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
+      );
+    }
+  }
+
+  _getLanguage(metaInfo: 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 metaInfo.language ?? LANGUAGE_MAP.get(metaInfo.content_type);
+  }
+
+  /**
+   * Start processing syntax for the loaded diff and notify layer listeners
+   * as syntax info comes online.
+   */
+  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 = [];
+
+    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();
+    }
+
+    const state: SyntaxLayerState = {
+      sectionIndex: 0,
+      lineIndex: 0,
+      baseContext: undefined,
+      revisionContext: undefined,
+      lineNums: {left: 1, right: 1},
+      lastNotify: {left: 1, right: 1},
+    };
+
+    const rangesCache = new Map();
+
+    this._processPromise = util.makeCancelable(
+      this._loadHLJS().then(
+        () =>
+          new Promise(resolve => {
+            const nextStep = () => {
+              this._processHandle = null;
+              this._processNextLine(state, rangesCache);
+
+              // Move to the next line in the section.
+              state.lineIndex++;
+
+              // If the section has been exhausted, move to the next one.
+              if (this._isSectionDone(state)) {
+                state.lineIndex = 0;
+                state.sectionIndex++;
+              }
+
+              // If all sections have been exhausted, finish.
+              if (
+                !this.diff ||
+                state.sectionIndex >= this.diff.content.length
+              ) {
+                resolve();
+                this._notify(state);
+                return;
+              }
+
+              if (state.lineIndex % 100 === 0) {
+                this._notify(state);
+                this._processHandle = this.async(nextStep, ASYNC_DELAY);
+              } else {
+                nextStep.call(this);
+              }
+            };
+
+            this._processHandle = this.async(nextStep, 1);
+          })
+      )
+    );
+    return this._processPromise.finally(() => {
+      this._processPromise = null;
+    });
+  }
+
+  /**
+   * Cancel any asynchronous syntax processing jobs.
+   */
+  cancel() {
+    if (this._processHandle !== null) {
+      this.cancelAsync(this._processHandle);
+      this._processHandle = null;
+    }
+    if (this._processPromise) {
+      this._processPromise.cancel();
+    }
+  }
+
+  _diffChanged() {
+    this.cancel();
+    this._baseRanges = [];
+    this._revisionRanges = [];
+  }
+
+  /**
+   * 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 str The string of HTML.
+   * @param 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 The list of ranges.
+   */
+  _rangesFromString(
+    str: string,
+    rangesCache: Map<string, SyntaxLayerRange[]>
+  ): SyntaxLayerRange[] {
+    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: Element, offset: number): SyntaxLayerRange[] {
+    let result: SyntaxLayerRange[] = [];
+    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 instanceof Element &&
+        node.tagName === 'SPAN' &&
+        node.className !== 'undefined'
+      ) {
+        if (CLASS_SAFELIST.has(node.className)) {
+          result.push({
+            start: offset,
+            length: nodeLength,
+            className: node.className,
+          });
+        }
+        if (node.children.length) {
+          result = result.concat(this._rangesFromElement(node, offset));
+        }
+      }
+      offset += nodeLength;
+    }
+    return result;
+  }
+
+  /**
+   * For a given state, process the syntax for the next line (or pair of
+   * lines).
+   */
+  _processNextLine(
+    state: SyntaxLayerState,
+    rangesCache: Map<string, SyntaxLayerRange[]>
+  ) {
+    if (!this.diff) return;
+    if (!this._hljs) return;
+
+    let baseLine;
+    let revisionLine;
+    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++;
+      }
+    }
+
+    // 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, 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, 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 language The name of the HLJS language plugin in use.
+   * @param line The line of code to potentially rewrite.
+   * @return A potentially-rewritten line of code.
+   */
+  _workaround(language: string, line: string) {
+    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;
+    }
+
+    /**
+     * 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}
+     */
+    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, '"\\\\"');
+    }
+
+    return line;
+  }
+
+  /**
+   * Tells whether the state has exhausted its current section.
+   */
+  _isSectionDone(state: SyntaxLayerState) {
+    if (!this.diff) return true;
+    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.
+   */
+  _notify(state: SyntaxLayerState) {
+    if (state.lineNums.left - state.lastNotify.left) {
+      this._notifyRange(state.lastNotify.left, state.lineNums.left, Side.LEFT);
+      state.lastNotify.left = state.lineNums.left;
+    }
+    if (state.lineNums.right - state.lastNotify.right) {
+      this._notifyRange(
+        state.lastNotify.right,
+        state.lineNums.right,
+        Side.RIGHT
+      );
+      state.lastNotify.right = state.lineNums.right;
+    }
+  }
+
+  _notifyRange(start: number, end: number, side: Side) {
+    for (const listener of this._listeners) {
+      listener(start, end, side);
+    }
+  }
+
+  _loadHLJS() {
+    return this.$.libLoader.getHLJS().then(hljs => {
+      this._hljs = hljs;
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-syntax-layer': 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
deleted file mode 100644
index 433f814..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
new file mode 100644
index 0000000..ac59f4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
@@ -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';
+
+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
deleted file mode 100644
index ccdbe8b..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ /dev/null
@@ -1,503 +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">
-<meta charset="utf-8">
-<title>gr-syntax-layer</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-syntax-layer></gr-syntax-layer>
-  </template>
-</test-fixture>
-
-<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';
-
-suite('gr-syntax-layer tests', () => {
-  let sandbox;
-  let diff;
-  let element;
-  const lineNumberEl = document.createElement('td');
-
-  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 {};
-      },
-    };
-  }
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    diff = getMockDiffResponse();
-    element.diff = diff;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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;
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isFalse(annotationSpy.called);
-  });
-
-  test('annotate with range applies it', () => {
-    const str = 'Etiam dui, blandit wisi.';
-    const start = 6;
-    const length = 3;
-    const className = 'foobar';
-
-    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.annotate(el, lineNumberEl, line);
-
-    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));
-  });
-
-  test('annotate with range but disabled does nothing', () => {
-    const str = 'Etiam dui, blandit wisi.';
-    const start = 6;
-    const length = 3;
-    const className = 'foobar';
-
-    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;
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isFalse(annotationSpy.called);
-  });
-
-  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');
-
-    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 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-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
new file mode 100644
index 0000000..6a2bbca
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
@@ -0,0 +1,482 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import './gr-syntax-layer.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
+
+const basicFixture = fixtureFromElement('gr-syntax-layer');
+
+suite('gr-syntax-layer tests', () => {
+  let diff;
+  let element;
+  const lineNumberEl = document.createElement('td');
+
+  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 {};
+      },
+    };
+  }
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    diff = getMockDiffResponse();
+    element.diff = diff;
+  });
+
+  test('annotate without range does nothing', () => {
+    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = 'Etiam dui, blandit wisi.';
+    const line = new GrDiffLine(GrDiffLineType.REMOVE);
+    line.beforeNumber = 12;
+
+    element.annotate(el, lineNumberEl, line);
+
+    assert.isFalse(annotationSpy.called);
+  });
+
+  test('annotate with range applies it', () => {
+    const str = 'Etiam dui, blandit wisi.';
+    const start = 6;
+    const length = 3;
+    const className = 'foobar';
+
+    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = str;
+    const line = new GrDiffLine(GrDiffLineType.REMOVE);
+    line.beforeNumber = 12;
+    element._baseRanges[11] = [{
+      start,
+      length,
+      className,
+    }];
+
+    element.annotate(el, lineNumberEl, line);
+
+    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));
+  });
+
+  test('annotate with range but disabled does nothing', () => {
+    const str = 'Etiam dui, blandit wisi.';
+    const start = 6;
+    const length = 3;
+    const className = 'foobar';
+
+    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = str;
+    const line = new GrDiffLine(GrDiffLineType.REMOVE);
+    line.beforeNumber = 12;
+    element._baseRanges[11] = [{
+      start,
+      length,
+      className,
+    }];
+    element.enabled = false;
+
+    element.annotate(el, lineNumberEl, line);
+
+    assert.isFalse(annotationSpy.called);
+  });
+
+  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 = sinon.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 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 = sinon.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 = sinon.spy(element, '_processNextLine');
+    element.enabled = false;
+    const loadHLJSSpy = sinon.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');
+    sinon.stub(element.$.libLoader, 'getHLJS').callsFake(
+        () => Promise.resolve(mockHLJS));
+    const processNextSpy = sinon.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 = sinon.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-allowed', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui, blandit';
+    const str2 = ' wisi.';
+    const className = 'not-in-the-safelist';
+    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 safelist 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', () => {
+    sinon.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);
+  });
+});
+
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
deleted file mode 100644
index 76a01de..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js
+++ /dev/null
@@ -1,121 +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.
- */
-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/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
new file mode 100644
index 0000000..94e5f87
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -0,0 +1,129 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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-property {
+        color: var(--syntax-property-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.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
deleted file mode 100644
index 4d66699..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
+++ /dev/null
@@ -1,101 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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',
-      },
-
-      _path: {
-        type: String,
-        readOnly: true,
-        value: '/Documentation',
-      },
-      _documentationSearches: Array,
-
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.dispatchEvent(
-        new CustomEvent('title-change', {title: 'Documentation Search'}));
-  }
-
-  _paramsChanged(params) {
-    this._loading = true;
-    this._filter = this.getFilterValue(params);
-
-    return this._getDocumentationSearches(this._filter);
-  }
-
-  _getDocumentationSearches(filter) {
-    this._documentationSearches = [];
-    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;
-  }
-}
-
-customElements.define(GrDocumentationSearch.is, GrDocumentationSearch);
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
new file mode 100644
index 0000000..5967b03
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -0,0 +1,101 @@
+/**
+ * @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.
+ */
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-documentation-search_html';
+import {
+  ListViewMixin,
+  ListViewParams,
+} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {getBaseUrl} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {DocResult} from '../../../types/common';
+
+export interface GrDocumentationSearch {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-documentation-search')
+export class GrDocumentationSearch extends ListViewMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * URL params passed from the router.
+   */
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: ListViewParams;
+
+  @property({type: Array})
+  _documentationSearches?: DocResult[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _filter = '';
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.dispatchEvent(
+      new CustomEvent('title-change', {detail: {title: 'Documentation Search'}})
+    );
+  }
+
+  _paramsChanged(params: ListViewParams) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+
+    return this._getDocumentationSearches(this._filter);
+  }
+
+  _getDocumentationSearches(filter: string) {
+    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?: string) {
+    if (!url) {
+      return '';
+    }
+    return `${getBaseUrl()}/${url}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-documentation-search': 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
deleted file mode 100644
index b637b75..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
new file mode 100644
index 0000000..9f5aae3
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
@@ -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';
+
+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="/Documentation"
+  >
+    <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
deleted file mode 100644
index d0581ef..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
+++ /dev/null
@@ -1,124 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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>
-    <gr-documentation-search></gr-documentation-search>
-  </template>
-</test-fixture>
-
-<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;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    sandbox.stub(page, 'show');
-    element = fixture('basic');
-    counter = 0;
-  });
-
-  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);
-      _.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/documentation/gr-documentation-search/gr-documentation-search_test.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
new file mode 100644
index 0000000..ce80f2f
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
@@ -0,0 +1,104 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-documentation-search.js';
+import {page} from '../../../utils/page-wrapper-utils.js';
+import 'lodash/lodash.js';
+
+const basicFixture = fixtureFromElement('gr-documentation-search');
+
+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 value;
+
+  setup(() => {
+    sinon.stub(page, 'show');
+    element = basicFixture.instantiate();
+    counter = 0;
+  });
+
+  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);
+      _.times(1, documentationSearches);
+    });
+
+    test('_paramsChanged', done => {
+      sinon.stub(
+          element.$.restAPI,
+          'getDocumentationSearches')
+          .callsFake(() => 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);
+
+      flush();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+});
+
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
deleted file mode 100644
index 09f4abf..0000000
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
+++ /dev/null
@@ -1,51 +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.
- */
-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-default-editor_html.js';
-
-/** @extends Polymer.Element */
-class GrDefaultEditor extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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}));
-  }
-}
-
-customElements.define(GrDefaultEditor.is, GrDefaultEditor);
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
new file mode 100644
index 0000000..4c63303
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -0,0 +1,61 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-default-editor_html';
+import {customElement, property} from '@polymer/decorators';
+
+export interface GrDefaultEditor {
+  $: {};
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-default-editor': GrDefaultEditor;
+  }
+}
+
+@customElement('gr-default-editor')
+/** @extends PolymerElement */
+export class GrDefaultEditor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the content of the editor changes.
+   *
+   * @event content-change
+   */
+
+  @property({type: String})
+  fileContent: string | null = null;
+
+  _handleTextareaInput(e: Event) {
+    this.dispatchEvent(
+      new CustomEvent('content-change', {
+        detail: {value: (e.target as HTMLTextAreaElement).value},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+}
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
deleted file mode 100644
index 7368289..0000000
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
new file mode 100644
index 0000000..ba8af3c
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 229c6c3..0000000
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
+++ /dev/null
@@ -1,56 +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.
--->
-
-<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="/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-default-editor></gr-default-editor>
-  </template>
-</test-fixture>
-
-<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}));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.js
new file mode 100644
index 0000000..d40e83d
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.js
@@ -0,0 +1,43 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-default-editor.js';
+
+const basicFixture = fixtureFromElement('gr-default-editor');
+
+suite('gr-default-editor tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    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}));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.js b/polygerrit-ui/app/elements/edit/gr-edit-constants.js
deleted file mode 100644
index 7282a46..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.js
+++ /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.
- */
-
-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-constants.ts b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
new file mode 100644
index 0000000..af3fbb2
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
@@ -0,0 +1,31 @@
+/**
+ * @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 interface GrEditAction {
+  label: string;
+  id: string;
+}
+
+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.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
deleted file mode 100644
index 67357c4..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
+++ /dev/null
@@ -1,312 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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,
-
-      /**
-       * TODO(kaspern): by default, the RESTORE action should be hidden in the
-       * file-list as it is a per-file action only. Remove this default value
-       * when the Actions dictionary is moved to a shared constants file and
-       * use the hiddenActions property in the parent component.
-       */
-      hiddenActions: {
-        type: Array,
-        value() { return [GrEditConstants.Actions.RESTORE.id]; },
-      },
-
-      _actions: {
-        type: Array,
-        value() { return Object.values(GrEditConstants.Actions); },
-      },
-      _path: {
-        type: String,
-        value: '',
-      },
-      _newPath: {
-        type: String,
-        value: '',
-      },
-      _query: {
-        type: Function,
-        value() {
-          return this._queryFiles.bind(this);
-        },
-      },
-    };
-  }
-
-  _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;
-    }
-  }
-
-  /**
-   * @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
-   */
-  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);
-  }
-
-  /**
-   * 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);
-  }
-
-  /**
-   * 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');
-    });
-  }
-
-  _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);
-    });
-  }
-
-  _hideAllDialogs() {
-    const dialogs = 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; }
-
-    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.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;
-      }
-
-      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);
-    }
-  }
-
-  _handleKeyPress(event) {
-    event.preventDefault();
-    event.stopImmediatePropagation();
-  }
-}
-
-customElements.define(GrEditControls.is, GrEditControls);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
new file mode 100644
index 0000000..7723050
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -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.
+ */
+import '@polymer/iron-input/iron-input';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-edit-controls_html';
+import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+export interface GrEditControls {
+  $: {
+    restAPI: RestApiService & Element;
+    overlay: GrOverlay;
+    openDialog: GrDialog;
+    deleteDialog: GrDialog;
+    renameDialog: GrDialog;
+    restoreDialog: GrDialog;
+  };
+}
+
+@customElement('gr-edit-controls')
+export class GrEditControls extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  change!: ChangeInfo;
+
+  @property({type: String})
+  patchNum!: PatchSetNum;
+
+  @property({type: Array})
+  hiddenActions: string[] = [GrEditConstants.Actions.RESTORE.id];
+
+  @property({type: Array})
+  _actions: GrEditAction[] = Object.values(GrEditConstants.Actions);
+
+  @property({type: String})
+  _path = '';
+
+  @property({type: String})
+  _newPath = '';
+
+  @property({type: Object})
+  _query: AutocompleteQuery;
+
+  constructor() {
+    super();
+    this._query = (input: string) => this._queryFiles(input);
+  }
+
+  _handleTap(e: Event) {
+    e.preventDefault();
+    const target = (dom(e) as EventApi).localTarget as Element;
+    const action = target.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;
+    }
+  }
+
+  openOpenDialog(path?: string) {
+    if (path) {
+      this._path = path;
+    }
+    return this._showDialog(this.$.openDialog);
+  }
+
+  openDeleteDialog(path?: string) {
+    if (path) {
+      this._path = path;
+    }
+    return this._showDialog(this.$.deleteDialog);
+  }
+
+  openRenameDialog(path?: string) {
+    if (path) {
+      this._path = path;
+    }
+    return this._showDialog(this.$.renameDialog);
+  }
+
+  openRestoreDialog(path?: string) {
+    if (path) {
+      this._path = path;
+    }
+    return this._showDialog(this.$.restoreDialog);
+  }
+
+  /**
+   * Given a path string, checks that it is a valid file path.
+   */
+  _isValidPath(path: string) {
+    // Double negation needed for strict boolean return type.
+    return !!path.length && !path.endsWith('/');
+  }
+
+  _computeRenameDisabled(path: string, newPath: string) {
+    return this._isValidPath(path) && this._isValidPath(newPath);
+  }
+
+  /**
+   * Given a dom event, gets the dialog that lies along this event path.
+   */
+  _getDialogFromEvent(e: Event): GrDialog | undefined {
+    return (dom(e) as EventApi).path.find(element => {
+      if (!(element instanceof Element)) return false;
+      if (!element.classList) return false;
+      return element.classList.contains('dialog');
+    }) as GrDialog | undefined;
+  }
+
+  _showDialog(dialog: GrDialog) {
+    // 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);
+    });
+  }
+
+  _hideAllDialogs() {
+    const dialogs = this.root!.querySelectorAll('.dialog') as NodeListOf<
+      GrDialog
+    >;
+    for (const dialog of dialogs) {
+      this._closeDialog(dialog);
+    }
+  }
+
+  _closeDialog(dialog?: GrDialog, clearInputs = false) {
+    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 = '';
+      });
+
+      dialog.querySelectorAll('iron-input').forEach(input => {
+        input.bindValue = '';
+      });
+    }
+
+    dialog.classList.toggle('invisible', true);
+    return this.$.overlay.close();
+  }
+
+  _handleDialogCancel(e: Event) {
+    this._closeDialog(this._getDialogFromEvent(e));
+  }
+
+  _handleOpenConfirm(e: Event) {
+    const url = GerritNav.getEditUrlForDiff(
+      this.change,
+      this._path,
+      this.patchNum
+    );
+    GerritNav.navigateToRelativeUrl(url);
+    this._closeDialog(this._getDialogFromEvent(e), true);
+  }
+
+  _handleUploadConfirm(path: string, fileData: string) {
+    if (!this.change || !path || !fileData) {
+      this._closeDialog(this.$.openDialog, true);
+      return;
+    }
+    return this.$.restAPI
+      .saveFileUploadChangeEdit(this.change._number, path, fileData)
+      .then(res => {
+        if (!res || !res.ok) {
+          return;
+        }
+        this._closeDialog(this.$.openDialog, true);
+        GerritNav.navigateToChange(this.change);
+      });
+  }
+
+  _handleDeleteConfirm(e: Event) {
+    // Get the dialog before the api call as the event will change during bubbling
+    // which will make Polymer.dom(e).path an empty array in polymer 2
+    const dialog = this._getDialogFromEvent(e);
+    this.$.restAPI
+      .deleteFileInChangeEdit(this.change._number, this._path)
+      .then(res => {
+        if (!res || !res.ok) {
+          return;
+        }
+        this._closeDialog(dialog, true);
+        GerritNav.navigateToChange(this.change);
+      });
+  }
+
+  _handleRestoreConfirm(e: Event) {
+    const dialog = this._getDialogFromEvent(e);
+    this.$.restAPI
+      .restoreFileInChangeEdit(this.change._number, this._path)
+      .then(res => {
+        if (!res || !res.ok) {
+          return;
+        }
+        this._closeDialog(dialog, true);
+        GerritNav.navigateToChange(this.change);
+      });
+  }
+
+  _handleRenameConfirm(e: Event) {
+    const dialog = this._getDialogFromEvent(e);
+    return this.$.restAPI
+      .renameFileInChangeEdit(this.change._number, this._path, this._newPath)
+      .then(res => {
+        if (!res || !res.ok) {
+          return;
+        }
+        this._closeDialog(dialog, true);
+        GerritNav.navigateToChange(this.change);
+      });
+  }
+
+  _queryFiles(input: string): Promise<AutocompleteSuggestion[]> {
+    return this.$.restAPI
+      .queryChangeFiles(this.change._number, this.patchNum, input)
+      .then(res => {
+        if (!res) throw new Error('Failed to retrieve files. Reponse not set.');
+        return res.map(file => {
+          return {name: file};
+        });
+      });
+  }
+
+  _computeIsInvisible(id: string, hiddenActions: string[]) {
+    return hiddenActions.includes(id) ? 'invisible' : '';
+  }
+
+  _handleDragAndDropUpload(event: DragEvent) {
+    event.preventDefault();
+    event.stopPropagation();
+
+    if (!event.dataTransfer) return;
+    this._fileUpload(event.dataTransfer.files);
+  }
+
+  _handleFileUploadChanged(event: InputEvent) {
+    if (!event.target) return;
+    if (!(event.target instanceof HTMLInputElement)) return;
+    const input = event.target as HTMLInputElement;
+    if (!input.files) return;
+    this._fileUpload(input.files);
+  }
+
+  _fileUpload(files: FileList) {
+    for (const file of files) {
+      if (!file) continue;
+
+      let path = this._path;
+      if (!path) {
+        path = file.name;
+      }
+
+      const fr = new FileReader();
+      // TODO(TS): Do we need this line?
+      // fr.file = file;
+      fr.onload = (fileLoadEvent: ProgressEvent<FileReader>) => {
+        if (!fileLoadEvent) return;
+        const fileData = fileLoadEvent.target!.result;
+        if (typeof fileData !== 'string') return;
+        this._handleUploadConfirm(path, fileData);
+      };
+      fr.readAsDataURL(file);
+    }
+  }
+
+  _handleKeyPress(event: InputEvent) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-edit-controls': 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
deleted file mode 100644
index 73487de..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js
+++ /dev/null
@@ -1,191 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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"
-          contenteditable="true"
-          on-drop="_handleDragAndDropUpload"
-          on-keypress="_handleKeyPress"
-        >
-          <p contenteditable="false">Drag and drop a file here</p>
-          <p contenteditable="false">or</p>
-          <p contenteditable="false">
-            <iron-input>
-              <input
-                is="iron-input"
-                id="fileUploadInput"
-                type="file"
-                on-change="_handleFileUploadChanged"
-                multiple
-                hidden
-              />
-            </iron-input>
-            <label for="fileUploadInput">
-              <gr-button id="fileUploadBrowse" contenteditable="false"
-                >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_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
new file mode 100644
index 0000000..1d86d61
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
@@ -0,0 +1,189 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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"
+          contenteditable="true"
+          on-drop="_handleDragAndDropUpload"
+          on-keypress="_handleKeyPress"
+        >
+          <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"
+                multiple
+                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
deleted file mode 100644
index 1267525..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
+++ /dev/null
@@ -1,433 +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.
--->
-
-<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="/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-edit-controls></gr-edit-controls>
-  </template>
-</test-fixture>
-
-<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;
-  let showDialogSpy;
-  let closeDialogSpy;
-  let queryStub;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.change = {_number: '42'};
-    showDialogSpy = sandbox.spy(element, '_showDialog');
-    closeDialogSpy = sandbox.spy(element, '_closeDialog');
-    sandbox.stub(element, '_hideAllDialogs');
-    queryStub = sandbox.stub(element.$.restAPI, 'queryChangeFiles')
-        .returns(Promise.resolve([]));
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('all actions exist', () => {
-    // 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);
-  });
-
-  suite('edit button CUJ', () => {
-    let navStubs;
-    let openAutoCcmplete;
-
-    setup(() => {
-      navStubs = [
-        sandbox.stub(GerritNav, 'getEditUrlForDiff'),
-        sandbox.stub(GerritNav, 'navigateToRelativeUrl'),
-      ];
-      openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
-    });
-
-    test('_isValidPath', () => {
-      assert.isFalse(element._isValidPath(''));
-      assert.isFalse(element._isValidPath('test/'));
-      assert.isFalse(element._isValidPath('/'));
-      assert.isTrue(element._isValidPath('test/path.cpp'));
-      assert.isTrue(element._isValidPath('test.js'));
-    });
-
-    test('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.shadowRoot
-            .querySelector('gr-button[primary]'));
-        for (const stub of navStubs) { assert.isTrue(stub.called); }
-        assert.deepEqual(GerritNav.getEditUrlForDiff.lastCall.args,
-            [element.change, 'src/test.cpp', element.patchNum]);
-        assert.isTrue(closeDialogSpy.called);
-      });
-    });
-
-    test('cancel', () => {
-      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.shadowRoot
-            .querySelector('gr-button'));
-        for (const stub of navStubs) { assert.isFalse(stub.called); }
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-      });
-    });
-  });
-
-  suite('delete button CUJ', () => {
-    let navStub;
-    let deleteStub;
-    let deleteAutocomplete;
-
-    setup(() => {
-      navStub = sandbox.stub(GerritNav, 'navigateToChange');
-      deleteStub = sandbox.stub(element.$.restAPI, 'deleteFileInChangeEdit');
-      deleteAutocomplete =
-          element.$.deleteDialog.querySelector('gr-autocomplete');
-    });
-
-    test('delete', () => {
-      deleteStub.returns(Promise.resolve({ok: true}));
-      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.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(deleteStub.called);
-
-        return deleteStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.isTrue(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('delete fails', () => {
-      deleteStub.returns(Promise.resolve({ok: false}));
-      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.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(deleteStub.called);
-
-        return deleteStub.lastCall.returnValue.then(() => {
-          assert.isFalse(navStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      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.shadowRoot
-            .querySelector('gr-button'));
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-      });
-    });
-  });
-
-  suite('rename button CUJ', () => {
-    let navStub;
-    let renameStub;
-    let renameAutocomplete;
-    const inputSelector = PolymerElement ?
-      '.newPathIronInput' :
-      '.newPathInput';
-
-    setup(() => {
-      navStub = sandbox.stub(GerritNav, 'navigateToChange');
-      renameStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
-      renameAutocomplete =
-          element.$.renameDialog.querySelector('gr-autocomplete');
-    });
-
-    test('rename', () => {
-      renameStub.returns(Promise.resolve({ok: true}));
-      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);
-        assert.isTrue(element.$.renameDialog.disabled);
-
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
-
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(renameStub.called);
-
-        return renameStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.isTrue(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('rename fails', () => {
-      renameStub.returns(Promise.resolve({ok: false}));
-      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);
-        assert.isTrue(element.$.renameDialog.disabled);
-
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
-
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(renameStub.called);
-
-        return renameStub.lastCall.returnValue.then(() => {
-          assert.isFalse(navStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.renameDialog.disabled);
-        element.$.renameDialog.querySelector('gr-autocomplete').text =
-            'src/test.cpp';
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button'));
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-        assert.equal(element._newPath, 'src/test.newPath');
-      });
-    });
-  });
-
-  suite('restore button CUJ', () => {
-    let navStub;
-    let restoreStub;
-
-    setup(() => {
-      navStub = sandbox.stub(GerritNav, 'navigateToChange');
-      restoreStub = sandbox.stub(element.$.restAPI, 'restoreFileInChangeEdit');
-    });
-
-    test('restore hidden by default', () => {
-      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.shadowRoot.querySelector('#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.isTrue(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('restore fails', () => {
-      restoreStub.returns(Promise.resolve({ok: false}));
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.isFalse(navStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button'));
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.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', () => {
-    const spy = sandbox.spy(element, '_getDialogFromEvent');
-    element.addEventListener('tap', element._getDialogFromEvent);
-
-    MockInteractions.tap(element.$.openDialog);
-    flushAsynchronousOperations();
-    assert.equal(spy.lastCall.returnValue.id, 'openDialog');
-
-    MockInteractions.tap(element.$.deleteDialog);
-    flushAsynchronousOperations();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
-
-    MockInteractions.tap(
-        element.$.deleteDialog.querySelector('gr-autocomplete'));
-    flushAsynchronousOperations();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
-
-    MockInteractions.tap(element);
-    flushAsynchronousOperations();
-    assert.notOk(spy.lastCall.returnValue);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
new file mode 100644
index 0000000..98d1545
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
@@ -0,0 +1,422 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-edit-controls.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-edit-controls');
+
+suite('gr-edit-controls tests', () => {
+  let element;
+
+  let showDialogSpy;
+  let closeDialogSpy;
+  let queryStub;
+  let ironOverlayBackdropStyleEl;
+
+  setup(() => {
+    ironOverlayBackdropStyleEl = createIronOverlayBackdropStyleEl();
+    element = basicFixture.instantiate();
+    element.change = {_number: '42'};
+    showDialogSpy = sinon.spy(element, '_showDialog');
+    closeDialogSpy = sinon.spy(element, '_closeDialog');
+    sinon.stub(element, '_hideAllDialogs');
+    queryStub = sinon.stub(element.$.restAPI, 'queryChangeFiles')
+        .returns(Promise.resolve([]));
+    flush();
+  });
+
+  teardown(() => {
+    ironOverlayBackdropStyleEl.remove();
+  });
+
+  test('all actions exist', () => {
+    // We take 1 away from the total found, due to an extra button being
+    // added for the file uploads (browse).
+    assert.equal(
+        element.root.querySelectorAll('gr-button').length - 1,
+        element._actions.length);
+  });
+
+  suite('edit button CUJ', () => {
+    let navStubs;
+    let openAutoCcmplete;
+
+    setup(() => {
+      navStubs = [
+        sinon.stub(GerritNav, 'getEditUrlForDiff'),
+        sinon.stub(GerritNav, 'navigateToRelativeUrl'),
+      ];
+      openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
+    });
+
+    test('_isValidPath', () => {
+      assert.isFalse(element._isValidPath(''));
+      assert.isFalse(element._isValidPath('test/'));
+      assert.isFalse(element._isValidPath('/'));
+      assert.isTrue(element._isValidPath('test/path.cpp'));
+      assert.isTrue(element._isValidPath('test.js'));
+    });
+
+    test('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.shadowRoot
+            .querySelector('gr-button[primary]'));
+        for (const stub of navStubs) { assert.isTrue(stub.called); }
+        assert.deepEqual(GerritNav.getEditUrlForDiff.lastCall.args,
+            [element.change, 'src/test.cpp', element.patchNum]);
+        assert.isTrue(closeDialogSpy.called);
+      });
+    });
+
+    test('cancel', () => {
+      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.shadowRoot
+            .querySelector('gr-button'));
+        for (const stub of navStubs) { assert.isFalse(stub.called); }
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  suite('delete button CUJ', () => {
+    let navStub;
+    let deleteStub;
+    let deleteAutocomplete;
+
+    setup(() => {
+      navStub = sinon.stub(GerritNav, 'navigateToChange');
+      deleteStub = sinon.stub(element.$.restAPI, 'deleteFileInChangeEdit');
+      deleteAutocomplete =
+          element.$.deleteDialog.querySelector('gr-autocomplete');
+    });
+
+    test('delete', () => {
+      deleteStub.returns(Promise.resolve({ok: true}));
+      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.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flush();
+
+        assert.isTrue(deleteStub.called);
+
+        return deleteStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('delete fails', () => {
+      deleteStub.returns(Promise.resolve({ok: false}));
+      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.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flush();
+
+        assert.isTrue(deleteStub.called);
+
+        return deleteStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      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.shadowRoot
+            .querySelector('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  suite('rename button CUJ', () => {
+    let navStub;
+    let renameStub;
+    let renameAutocomplete;
+    const inputSelector = PolymerElement ?
+      '.newPathIronInput' :
+      '.newPathInput';
+
+    setup(() => {
+      navStub = sinon.stub(GerritNav, 'navigateToChange');
+      renameStub = sinon.stub(element.$.restAPI, 'renameFileInChangeEdit');
+      renameAutocomplete =
+          element.$.renameDialog.querySelector('gr-autocomplete');
+    });
+
+    test('rename', () => {
+      renameStub.returns(Promise.resolve({ok: true}));
+      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);
+        assert.isTrue(element.$.renameDialog.disabled);
+
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
+            'src/test.newPath';
+
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flush();
+
+        assert.isTrue(renameStub.called);
+
+        return renameStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('rename fails', () => {
+      renameStub.returns(Promise.resolve({ok: false}));
+      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);
+        assert.isTrue(element.$.renameDialog.disabled);
+
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
+            'src/test.newPath';
+
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flush();
+
+        assert.isTrue(renameStub.called);
+
+        return renameStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        element.$.renameDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
+            'src/test.newPath';
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._newPath, 'src/test.newPath');
+      });
+    });
+  });
+
+  suite('restore button CUJ', () => {
+    let navStub;
+    let restoreStub;
+
+    setup(() => {
+      navStub = sinon.stub(GerritNav, 'navigateToChange');
+      restoreStub = sinon.stub(element.$.restAPI, 'restoreFileInChangeEdit');
+    });
+
+    test('restore hidden by default', () => {
+      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.shadowRoot.querySelector('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flush();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('restore fails', () => {
+      restoreStub.returns(Promise.resolve({ok: false}));
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flush();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  suite('save file upload', () => {
+    let navStub;
+    let fileStub;
+
+    setup(() => {
+      navStub = sinon.stub(GerritNav, 'navigateToChange');
+      fileStub = sinon.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', () => {
+    const spy = sinon.spy(element, '_getDialogFromEvent');
+    element.addEventListener('tap', element._getDialogFromEvent);
+
+    MockInteractions.tap(element.$.openDialog);
+    flush();
+    assert.equal(spy.lastCall.returnValue.id, 'openDialog');
+
+    MockInteractions.tap(element.$.deleteDialog);
+    flush();
+    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+
+    MockInteractions.tap(
+        element.$.deleteDialog.querySelector('gr-autocomplete'));
+    flush();
+    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+
+    MockInteractions.tap(element);
+    flush();
+    assert.notOk(spy.lastCall.returnValue);
+  });
+});
+
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
deleted file mode 100644
index 8a24e23..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
+++ /dev/null
@@ -1,78 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @extends Polymer.Element */
-class GrEditFileControls extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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,
-        value: () => Object.values(GrEditConstants.Actions),
-      },
-      _fileActions: {
-        type: Array,
-        computed: '_computeFileActions(_allFileActions)',
-      },
-    };
-  }
-
-  _handleActionTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this._dispatchFileAction(e.detail.id, this.filePath);
-  }
-
-  _dispatchFileAction(action, path) {
-    this.dispatchEvent(new CustomEvent(
-        'file-action-tap',
-        {detail: {action, path}, bubbles: true, composed: true}));
-  }
-
-  _computeFileActions(actions) {
-    // TODO(kaspern): conditionally disable some actions based on file status.
-    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.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
new file mode 100644
index 0000000..9f3d1bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -0,0 +1,87 @@
+/**
+ * @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 '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-edit-file-controls_html';
+import {GrEditConstants} from '../gr-edit-constants';
+import {customElement, property} from '@polymer/decorators';
+
+interface EditAction {
+  label: string;
+  id: string;
+}
+
+/** @extends PolymerElement */
+@customElement('gr-edit-file-controls')
+class GrEditFileControls extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when an action in the overflow menu is tapped.
+   *
+   * @event file-action-tap
+   */
+
+  @property({type: String})
+  filePath?: string;
+
+  @property({type: Array})
+  _allFileActions = Object.values(GrEditConstants.Actions);
+
+  @property({type: Array, computed: '_computeFileActions(_allFileActions)'})
+  _fileActions?: EditAction[];
+
+  _handleActionTap(e: CustomEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    this._dispatchFileAction(e.detail.id, this.filePath);
+  }
+
+  _dispatchFileAction(action: EditAction, path?: string) {
+    this.dispatchEvent(
+      new CustomEvent('file-action-tap', {
+        detail: {action, path},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+
+  _computeFileActions(actions: EditAction[]) {
+    // TODO(kaspern): conditionally disable some actions based on file status.
+    return actions.map(action => {
+      return {
+        name: action.label,
+        id: action.id,
+      };
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-edit-file-controls': 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
deleted file mode 100644
index ec0b8b4..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts
new file mode 100644
index 0000000..c6a6de7
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts
@@ -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';
+
+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
deleted file mode 100644
index e11a2bd..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
+++ /dev/null
@@ -1,109 +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.
--->
-
-<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="/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-edit-file-controls></gr-edit-file-controls>
-  </template>
-</test-fixture>
-
-<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;
-  let fileActionHandler;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    fileActionHandler = sandbox.stub();
-    element.addEventListener('file-action-tap', fileActionHandler);
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('open tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flushAsynchronousOperations();
-
-    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'});
-  });
-
-  test('delete tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flushAsynchronousOperations();
-
-    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'});
-  });
-
-  test('restore tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flushAsynchronousOperations();
-
-    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'});
-  });
-
-  test('rename tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flushAsynchronousOperations();
-
-    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'});
-  });
-
-  test('computed properties', () => {
-    assert.equal(element._allFileActions.length, 4);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
new file mode 100644
index 0000000..180a3a4
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
@@ -0,0 +1,92 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import '../gr-edit-constants.js';
+import './gr-edit-file-controls.js';
+import {GrEditConstants} from '../gr-edit-constants.js';
+
+const basicFixture = fixtureFromElement('gr-edit-file-controls');
+
+suite('gr-edit-file-controls tests', () => {
+  let element;
+
+  let fileActionHandler;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    fileActionHandler = sinon.stub();
+    element.addEventListener('file-action-tap', fileActionHandler);
+  });
+
+  test('open tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    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'});
+  });
+
+  test('delete tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    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'});
+  });
+
+  test('restore tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    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'});
+  });
+
+  test('rename tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    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'});
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._allFileActions.length, 4);
+  });
+});
+
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
deleted file mode 100644
index 6e4d8c6..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ /dev/null
@@ -1,307 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.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';
-
-const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
-
-/**
- * @extends Polymer.Element
- */
-class GrEditorView extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-  PatchSetBehavior,
-  PathListBehavior,
-  RESTClientBehavior,
-], 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 {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-
-      _change: {
-        type: Object,
-        observer: '_editChange',
-      },
-      _changeEditDetail: Object,
-      _changeNum: String,
-      _patchNum: String,
-      _path: String,
-      _type: String,
-      _content: String,
-      _newContent: String,
-      _saving: {
-        type: Boolean,
-        value: false,
-      },
-      _successfulSave: {
-        type: Boolean,
-        value: false,
-      },
-      _saveDisabled: {
-        type: Boolean,
-        value: true,
-        computed: '_computeSaveDisabled(_content, _newContent, _saving)',
-      },
-      _prefs: Object,
-      _lineNum: Number,
-    };
-  }
-
-  get keyBindings() {
-    return {
-      'ctrl+s meta+s': '_handleSaveShortcut',
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('content-change',
-        e => this._handleContentChange(e));
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getEditPrefs().then(prefs => { this._prefs = prefs; });
-  }
-
-  get storageKey() {
-    return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getEditPrefs() {
-    return this.$.restAPI.getEditPreferences();
-  }
-
-  _paramsChanged(value) {
-    if (value.view !== GerritNav.View.EDIT) {
-      return;
-    }
-
-    this._changeNum = value.changeNum;
-    this._path = value.path;
-    this._patchNum = value.patchNum || this.EDIT_NAME;
-    this._lineNum = value.lineNum;
-
-    // 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,
-      }));
-    });
-
-    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;
-    });
-  }
-
-  _editChange(value) {
-    if (!value) return;
-    if (value.status !== this.ChangeStatus.MERGED &&
-      value.status !== this.ChangeStatus.ABANDONED) return;
-    /* eslint-disable max-len */
-    const message =
-      'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.';
-    this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message},
-          bubbles: true,
-          composed: true,
-        })
-    );
-    GerritNav.navigateToChange(value);
-  }
-
-  _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() {
-    GerritNav.navigateToChange(this._change, undefined, undefined, true);
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
new file mode 100644
index 0000000..047a818
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -0,0 +1,408 @@
+/**
+ * @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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-storage/gr-storage';
+import '../gr-default-editor/gr-default-editor';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-editor-view_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+  GerritNav,
+  GenerateUrlEditViewParameters,
+} from '../../core/gr-navigation/gr-navigation';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
+import {computeTruncatedPath} from '../../../utils/path-list-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+  RestApiService,
+  ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ChangeInfo,
+  PatchSetNum,
+  EditPreferencesInfo,
+  Base64FileContent,
+  NumericChangeId,
+} from '../../../types/common';
+import {GrStorage} from '../../shared/gr-storage/gr-storage';
+import {HttpMethod, NotifyType} from '../../../constants/constants';
+import {changeIsMerged, changeIsAbandoned} from '../../../utils/change-util';
+
+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';
+const PUBLISHING_EDIT_MSG = 'Publishing edit...';
+const PUBLISH_FAILED_MSG = 'Failed to publish edit';
+
+const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
+
+export interface GrEditorView {
+  $: {
+    restAPI: RestApiService & Element;
+    storage: GrStorage;
+  };
+}
+@customElement('gr-editor-view')
+export class GrEditorView extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired to notify the user of
+   *
+   * @event show-alert
+   */
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: GenerateUrlEditViewParameters;
+
+  @property({type: Object, observer: '_editChange'})
+  _change?: ChangeInfo | null;
+
+  @property({type: Number})
+  _changeNum?: NumericChangeId;
+
+  @property({type: String})
+  _patchNum?: PatchSetNum;
+
+  @property({type: String})
+  _path?: string;
+
+  @property({type: String})
+  _type?: string;
+
+  @property({type: String})
+  _content?: string;
+
+  @property({type: String})
+  _newContent?: string;
+
+  @property({type: Boolean})
+  _saving = false;
+
+  @property({type: Boolean})
+  _successfulSave = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeSaveDisabled(_content, _newContent, _saving)',
+  })
+  _saveDisabled = true;
+
+  @property({type: Object})
+  _prefs?: EditPreferencesInfo;
+
+  @property({type: Number})
+  _lineNum?: number;
+
+  get keyBindings() {
+    return {
+      'ctrl+s meta+s': '_handleSaveShortcut',
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('content-change', e => {
+      this._handleContentChange(e as CustomEvent<{value: string}>);
+    });
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getEditPrefs().then(prefs => {
+      this._prefs = prefs;
+    });
+  }
+
+  get storageKey() {
+    return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getEditPrefs() {
+    return this.$.restAPI.getEditPreferences();
+  }
+
+  _paramsChanged(value: GenerateUrlEditViewParameters) {
+    if (value.view !== GerritNav.View.EDIT) {
+      return;
+    }
+
+    this._changeNum = value.changeNum;
+    this._path = value.path;
+    this._patchNum =
+      value.patchNum || (SPECIAL_PATCH_SET_NUM.EDIT as PatchSetNum);
+    this._lineNum =
+      typeof value.lineNum === 'string' ? Number(value.lineNum) : value.lineNum;
+
+    // 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 ${computeTruncatedPath(value.path)}`;
+      this.dispatchEvent(
+        new CustomEvent('title-change', {
+          detail: {title},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+
+    const promises = [];
+
+    promises.push(this._getChangeDetail(this._changeNum));
+    promises.push(
+      this._getFileData(this._changeNum, this._path, this._patchNum)
+    );
+    return Promise.all(promises);
+  }
+
+  _getChangeDetail(changeNum: NumericChangeId) {
+    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+      this._change = change;
+    });
+  }
+
+  _editChange(value?: ChangeInfo | null) {
+    if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
+    if (!value) return;
+    const message =
+      'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.';
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {message},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    GerritNav.navigateToChange(value);
+  }
+
+  _handlePathChanged(e: CustomEvent<string>) {
+    // TODO(TS) could be cleand up, it was added for type requirements
+    if (this._changeNum === undefined || !this._path) {
+      return Promise.reject(new Error('changeNum or path undefined'));
+    }
+    const path = e.detail;
+    if (path === this._path) {
+      return Promise.resolve();
+    }
+    return this.$.restAPI
+      .renameFileInChangeEdit(this._changeNum, this._path, path)
+      .then(res => {
+        if (!res || !res.ok) {
+          return;
+        }
+
+        this._successfulSave = true;
+        this._viewEditInChangeView();
+      });
+  }
+
+  _viewEditInChangeView() {
+    if (this._change)
+      GerritNav.navigateToChange(this._change, undefined, undefined, true);
+  }
+
+  _getFileData(
+    changeNum: NumericChangeId,
+    path: string,
+    patchNum?: PatchSetNum
+  ) {
+    if (patchNum === undefined) {
+      return Promise.reject(new Error('patchNum undefined'));
+    }
+    const storedContent = this.$.storage.getEditableContentItem(
+      this.storageKey
+    );
+
+    return this.$.restAPI
+      .getFileContent(changeNum, path, patchNum)
+      .then(res => {
+        const content = (res && (res as Base64FileContent).content) || '';
+        if (
+          storedContent &&
+          storedContent.message &&
+          storedContent.message !== content
+        ) {
+          this.dispatchEvent(
+            new CustomEvent('show-alert', {
+              detail: {message: RESTORED_MESSAGE},
+              bubbles: true,
+              composed: true,
+            })
+          );
+
+          this._newContent = storedContent.message;
+        } else {
+          this._newContent = content;
+        }
+        this._content = 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 && res.ok && res.type) {
+          this._type = res.type;
+        } else {
+          this._type = '';
+        }
+      });
+  }
+
+  _saveEdit() {
+    if (this._changeNum === undefined || !this._path) {
+      return Promise.reject(new Error('changeNum or path undefined'));
+    }
+    this._saving = true;
+    this._showAlert(SAVING_MESSAGE);
+    this.$.storage.eraseEditableContentItem(this.storageKey);
+    if (!this._newContent)
+      return Promise.reject(new Error('new content undefined'));
+    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 res;
+        }
+
+        this._content = this._newContent;
+        this._successfulSave = true;
+        return res;
+      });
+  }
+
+  _showAlert(message: string) {
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {message},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+
+  _computeSaveDisabled(
+    content?: string,
+    newContent?: string,
+    saving?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if ([content, newContent, saving].includes(undefined)) {
+      return true;
+    }
+
+    if (saving) {
+      return true;
+    }
+    return content === newContent;
+  }
+
+  _handleCloseTap() {
+    // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
+    this._viewEditInChangeView();
+  }
+
+  _handleSaveTap() {
+    this._saveEdit().then(res => {
+      if (res.ok) this._viewEditInChangeView();
+    });
+  }
+
+  _handlePublishTap() {
+    if (!this._changeNum) throw new Error('missing changeNum');
+
+    const changeNum = this._changeNum;
+    this._saveEdit().then(() => {
+      const handleError: ErrorCallback = response => {
+        this._showAlert(PUBLISH_FAILED_MSG);
+        console.error(response);
+      };
+
+      this._showAlert(PUBLISHING_EDIT_MSG);
+
+      this.$.restAPI
+        .executeChangeAction(
+          changeNum,
+          HttpMethod.POST,
+          '/edit:publish',
+          undefined,
+          {notify: NotifyType.NONE},
+          handleError
+        )
+        .then(() => {
+          if (!this._change) throw new Error('missing change');
+          GerritNav.navigateToChange(this._change);
+        });
+    });
+  }
+
+  _handleContentChange(e: CustomEvent<{value: string}>) {
+    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: KeyboardEvent) {
+    e.preventDefault();
+    if (!this._saveDisabled) {
+      this._saveEdit();
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-editor-view': 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
deleted file mode 100644
index 9dc35b2..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
new file mode 100644
index 0000000..2455f61
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--view-background-color);
+    }
+    .stickyHeader {
+      background-color: var(--edit-mode-background-color);
+      border-bottom: 1px var(--border-color) solid;
+      position: sticky;
+      top: 0;
+      z-index: 1;
+    }
+    header,
+    .subHeader {
+      align-items: center;
+      display: flex;
+      flex-wrap: wrap;
+      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;
+    }
+  </style>
+  <div class="stickyHeader">
+    <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"
+          >Cancel</gr-button
+        >
+        <gr-button
+          id="save"
+          disabled$="[[_saveDisabled]]"
+          primary=""
+          link=""
+          title="Save and Close the file"
+          on-click="_handleSaveTap"
+          >Save</gr-button
+        >
+        <gr-button
+          id="publish"
+          link=""
+          primary=""
+          title="Publish your edit. A new patchset will be created."
+          on-click="_handlePublishTap"
+          disabled$="[[_saveDisabled]]"
+          >Save & Publish</gr-button
+        >
+      </span>
+    </header>
+  </div>
+  <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
deleted file mode 100644
index f353a1c..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
+++ /dev/null
@@ -1,407 +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.
--->
-
-<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="/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-editor-view></gr-editor-view>
-  </template>
-</test-fixture>
-
-<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;
-  let savePathStub;
-  let saveFileStub;
-  let changeDetailStub;
-  let navigateStub;
-  const mockParams = {
-    changeNum: '42',
-    path: 'foo/bar.baz',
-    patchNum: 'edit',
-  };
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getEditPreferences() { return Promise.resolve({}); },
-    });
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    savePathStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
-    saveFileStub = sandbox.stub(element.$.restAPI, 'saveChangeEdit');
-    changeDetailStub = sandbox.stub(element.$.restAPI, 'getDiffChangeDetail');
-    navigateStub = sandbox.stub(element, '_viewEditInChangeView');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  suite('_paramsChanged', () => {
-    test('incorrect view returns immediately', () => {
-      element._paramsChanged(
-          Object.assign({}, mockParams, {view: GerritNav.View.DIFF}));
-      assert.notOk(element._changeNum);
-    });
-
-    test('good params proceed', () => {
-      changeDetailStub.returns(Promise.resolve({}));
-      const fileStub = sandbox.stub(element, '_getFileData', () => {
-        element._content = 'text';
-        element._newContent = 'text';
-        element._type = 'application/octet-stream';
-      });
-
-      const promises = element._paramsChanged(
-          Object.assign({}, mockParams, {view: GerritNav.View.EDIT}));
-
-      flushAsynchronousOperations();
-      assert.equal(element._changeNum, mockParams.changeNum);
-      assert.equal(element._path, mockParams.path);
-      assert.deepEqual(changeDetailStub.lastCall.args[0],
-          mockParams.changeNum);
-      assert.deepEqual(fileStub.lastCall.args,
-          [mockParams.changeNum, mockParams.path, mockParams.patchNum]);
-
-      return promises.then(() => {
-        assert.equal(element._content, 'text');
-        assert.equal(element._newContent, 'text');
-        assert.equal(element._type, 'application/octet-stream');
-      });
-    });
-  });
-
-  test('edit file path', () => {
-    element._changeNum = mockParams.changeNum;
-    element._path = mockParams.path;
-    savePathStub.onFirstCall().returns(Promise.resolve({}));
-    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
-
-    // Calling with the same path should not navigate.
-    return element._handlePathChanged({detail: mockParams.path}).then(() => {
-      assert.isFalse(savePathStub.called);
-      // !ok response
-      element._handlePathChanged({detail: 'newPath'}).then(() => {
-        assert.isTrue(savePathStub.called);
-        assert.isFalse(navigateStub.called);
-        // ok response
-        element._handlePathChanged({detail: 'newPath'}).then(() => {
-          assert.isTrue(navigateStub.called);
-          assert.isTrue(element._successfulSave);
-        });
-      });
-    });
-  });
-
-  test('reacts to content-change event', () => {
-    const storeStub = sandbox.spy(element.$.storage, 'setEditableContentItem');
-    element._newContent = 'test';
-    element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
-      bubbles: true, composed: true,
-      detail: {value: 'new content value'},
-    }));
-    element.flushDebouncer('store');
-    flushAsynchronousOperations();
-
-    assert.equal(element._newContent, 'new content value');
-    assert.isTrue(storeStub.called);
-    assert.equal(storeStub.lastCall.args[1], 'new content value');
-  });
-
-  suite('edit file content', () => {
-    const originalText = 'file text';
-    const newText = 'file text changed';
-
-    setup(() => {
-      element._changeNum = mockParams.changeNum;
-      element._path = mockParams.path;
-      element._content = originalText;
-      element._newContent = originalText;
-      flushAsynchronousOperations();
-    });
-
-    test('initial load', () => {
-      assert.equal(element.$.file.fileContent, originalText);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-    });
-
-    test('file modification and save, !ok response', () => {
-      const saveSpy = sandbox.spy(element, '_saveEdit');
-      const eraseStub = sandbox.stub(element.$.storage,
-          'eraseEditableContentItem');
-      const alertStub = sandbox.stub(element, '_showAlert');
-      saveFileStub.returns(Promise.resolve({ok: false}));
-      element._newContent = newText;
-      flushAsynchronousOperations();
-
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-      assert.isFalse(element._saving);
-
-      MockInteractions.tap(element.$.save);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isTrue(eraseStub.called);
-        assert.isFalse(element._saving);
-        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
-        assert.deepEqual(saveFileStub.lastCall.args,
-            [mockParams.changeNum, mockParams.path, newText]);
-        assert.isFalse(navigateStub.called);
-        assert.isFalse(element.$.save.hasAttribute('disabled'));
-        assert.notEqual(element._content, element._newContent);
-      });
-    });
-
-    test('file modification and save', () => {
-      const saveSpy = sandbox.spy(element, '_saveEdit');
-      const alertStub = sandbox.stub(element, '_showAlert');
-      saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flushAsynchronousOperations();
-
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.save);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
-        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
-        assert.isFalse(navigateStub.called);
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
-      });
-    });
-
-    test('file modification and close', () => {
-      const closeSpy = sandbox.spy(element, '_handleCloseTap');
-      element._newContent = newText;
-      flushAsynchronousOperations();
-
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.close);
-      assert.isTrue(closeSpy.called);
-      assert.isFalse(saveFileStub.called);
-      assert.isTrue(navigateStub.called);
-    });
-  });
-
-  suite('_getFileData', () => {
-    setup(() => {
-      element._newContent = 'initial';
-      element._content = 'initial';
-      element._type = 'initial';
-      sandbox.stub(element.$.storage, 'getEditableContentItem').returns(null);
-    });
-
-    test('res.ok', () => {
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'new content',
-          }));
-
-      // Ensure no data is set with a bad response.
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, 'new content');
-        assert.equal(element._content, 'new content');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('!res.ok', () => {
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({}));
-
-      // Ensure no data is set with a bad response.
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, '');
-      });
-    });
-
-    test('content is undefined', () => {
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-          }));
-
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('content and type is undefined', () => {
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-          }));
-
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, '');
-      });
-    });
-  });
-
-  test('_showAlert', done => {
-    element.addEventListener('show-alert', e => {
-      assert.deepEqual(e.detail, {message: 'test message'});
-      assert.isTrue(e.bubbles);
-      done();
-    });
-
-    element._showAlert('test message');
-  });
-
-  test('_viewEditInChangeView', () => {
-    navigateStub.restore();
-    const navStub = sandbox.stub(GerritNav, 'navigateToChange');
-    element._patchNum = element.EDIT_NAME;
-    element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], undefined);
-    assert.equal(navStub.lastCall.args[3], true);
-  });
-
-  suite('keyboard shortcuts', () => {
-    // Used as the spy on the handler for each entry in keyBindings.
-    let handleSpy;
-
-    suite('_handleSaveShortcut', () => {
-      let saveStub;
-      setup(() => {
-        handleSpy = sandbox.spy(element, '_handleSaveShortcut');
-        saveStub = sandbox.stub(element, '_saveEdit');
-      });
-
-      test('save enabled', () => {
-        element._content = '';
-        element._newContent = '_test';
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flushAsynchronousOperations();
-
-        assert.isTrue(handleSpy.calledOnce);
-        assert.isTrue(saveStub.calledOnce);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flushAsynchronousOperations();
-
-        assert.equal(handleSpy.callCount, 2);
-        assert.equal(saveStub.callCount, 2);
-      });
-
-      test('save disabled', () => {
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flushAsynchronousOperations();
-
-        assert.isTrue(handleSpy.calledOnce);
-        assert.isFalse(saveStub.called);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flushAsynchronousOperations();
-
-        assert.equal(handleSpy.callCount, 2);
-        assert.isFalse(saveStub.called);
-      });
-    });
-  });
-
-  suite('gr-storage caching', () => {
-    test('local edit exists', () => {
-      sandbox.stub(element.$.storage, 'getEditableContentItem')
-          .returns({message: 'pending edit'});
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'old content',
-          }));
-
-      const alertStub = sandbox.stub();
-      element.addEventListener('show-alert', alertStub);
-
-      return element._getFileData(1, 'test', 1).then(() => {
-        flushAsynchronousOperations();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(element._newContent, 'pending edit');
-        assert.equal(element._content, 'old content');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('local edit exists, is same as remote edit', () => {
-      sandbox.stub(element.$.storage, 'getEditableContentItem')
-          .returns({message: 'pending edit'});
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'pending edit',
-          }));
-
-      const alertStub = sandbox.stub();
-      element.addEventListener('show-alert', alertStub);
-
-      return element._getFileData(1, 'test', 1).then(() => {
-        flushAsynchronousOperations();
-
-        assert.isFalse(alertStub.called);
-        assert.equal(element._newContent, 'pending edit');
-        assert.equal(element._content, 'pending edit');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('storage key computation', () => {
-      element._changeNum = 1;
-      element._patchNum = 1;
-      element._path = 'test';
-      assert.equal(element.storageKey, 'c1_ps1_test');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
new file mode 100644
index 0000000..c0f615d
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
@@ -0,0 +1,431 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-editor-view.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {HttpMethod} from '../../../constants/constants.js';
+
+const basicFixture = fixtureFromElement('gr-editor-view');
+
+suite('gr-editor-view tests', () => {
+  let element;
+
+  let savePathStub;
+  let saveFileStub;
+  let changeDetailStub;
+  let navigateStub;
+  const mockParams = {
+    changeNum: '42',
+    path: 'foo/bar.baz',
+    patchNum: 'edit',
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getEditPreferences() { return Promise.resolve({}); },
+    });
+
+    element = basicFixture.instantiate();
+    savePathStub = sinon.stub(element.$.restAPI, 'renameFileInChangeEdit');
+    saveFileStub = sinon.stub(element.$.restAPI, 'saveChangeEdit');
+    changeDetailStub = sinon.stub(element.$.restAPI, 'getDiffChangeDetail');
+    navigateStub = sinon.stub(element, '_viewEditInChangeView');
+  });
+
+  suite('_paramsChanged', () => {
+    test('incorrect view returns immediately', () => {
+      element._paramsChanged(
+          {...mockParams, view: GerritNav.View.DIFF});
+      assert.notOk(element._changeNum);
+    });
+
+    test('good params proceed', () => {
+      changeDetailStub.returns(Promise.resolve({}));
+      const fileStub = sinon.stub(element, '_getFileData').callsFake(() => {
+        element._content = 'text';
+        element._newContent = 'text';
+        element._type = 'application/octet-stream';
+      });
+
+      const promises = element._paramsChanged(
+          {...mockParams, view: GerritNav.View.EDIT});
+
+      flush();
+      assert.equal(element._changeNum, mockParams.changeNum);
+      assert.equal(element._path, mockParams.path);
+      assert.deepEqual(changeDetailStub.lastCall.args[0],
+          mockParams.changeNum);
+      assert.deepEqual(fileStub.lastCall.args,
+          [mockParams.changeNum, mockParams.path, mockParams.patchNum]);
+
+      return promises.then(() => {
+        assert.equal(element._content, 'text');
+        assert.equal(element._newContent, 'text');
+        assert.equal(element._type, 'application/octet-stream');
+      });
+    });
+  });
+
+  test('edit file path', () => {
+    element._changeNum = mockParams.changeNum;
+    element._path = mockParams.path;
+    savePathStub.onFirstCall().returns(Promise.resolve({}));
+    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
+
+    // Calling with the same path should not navigate.
+    return element._handlePathChanged({detail: mockParams.path}).then(() => {
+      assert.isFalse(savePathStub.called);
+      // !ok response
+      element._handlePathChanged({detail: 'newPath'}).then(() => {
+        assert.isTrue(savePathStub.called);
+        assert.isFalse(navigateStub.called);
+        // ok response
+        element._handlePathChanged({detail: 'newPath'}).then(() => {
+          assert.isTrue(navigateStub.called);
+          assert.isTrue(element._successfulSave);
+        });
+      });
+    });
+  });
+
+  test('reacts to content-change event', () => {
+    const storeStub = sinon.spy(element.$.storage, 'setEditableContentItem');
+    element._newContent = 'test';
+    element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
+      bubbles: true, composed: true,
+      detail: {value: 'new content value'},
+    }));
+    element.flushDebouncer('store');
+    flush();
+
+    assert.equal(element._newContent, 'new content value');
+    assert.isTrue(storeStub.called);
+    assert.equal(storeStub.lastCall.args[1], 'new content value');
+  });
+
+  suite('edit file content', () => {
+    const originalText = 'file text';
+    const newText = 'file text changed';
+
+    setup(() => {
+      element._changeNum = mockParams.changeNum;
+      element._path = mockParams.path;
+      element._content = originalText;
+      element._newContent = originalText;
+      flush();
+    });
+
+    test('initial load', () => {
+      assert.equal(element.$.file.fileContent, originalText);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+    });
+
+    test('file modification and save, !ok response', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const eraseStub = sinon.stub(element.$.storage,
+          'eraseEditableContentItem');
+      const alertStub = sinon.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: false}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element._saving);
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isTrue(eraseStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
+        assert.deepEqual(saveFileStub.lastCall.args,
+            [mockParams.changeNum, mockParams.path, newText]);
+        assert.isFalse(navigateStub.called);
+        assert.isFalse(element.$.save.hasAttribute('disabled'));
+        assert.notEqual(element._content, element._newContent);
+      });
+    });
+
+    test('file modification and save', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const alertStub = sinon.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+        assert.isTrue(element._successfulSave);
+        assert.isTrue(navigateStub.called);
+      });
+    });
+
+    test('file modification and publish', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const alertStub = sinon.stub(element, '_showAlert');
+      const changeActionsStub =
+        sinon.stub(element.$.restAPI, 'executeChangeAction');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.publish);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+
+        assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
+        assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
+
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+        assert.isTrue(element._successfulSave);
+        assert.isFalse(navigateStub.called);
+
+        const args = changeActionsStub.lastCall.args;
+        assert.equal(args[0], '42');
+        assert.equal(args[1], HttpMethod.POST);
+        assert.equal(args[2], '/edit:publish');
+      });
+    });
+
+    test('file modification and close', () => {
+      const closeSpy = sinon.spy(element, '_handleCloseTap');
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.close);
+      assert.isTrue(closeSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isTrue(navigateStub.called);
+    });
+  });
+
+  suite('_getFileData', () => {
+    setup(() => {
+      element._newContent = 'initial';
+      element._content = 'initial';
+      element._type = 'initial';
+      sinon.stub(element.$.storage, 'getEditableContentItem').returns(null);
+    });
+
+    test('res.ok', () => {
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'new content',
+          }));
+
+      // Ensure no data is set with a bad response.
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, 'new content');
+        assert.equal(element._content, 'new content');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('!res.ok', () => {
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({}));
+
+      // Ensure no data is set with a bad response.
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, '');
+      });
+    });
+
+    test('content is undefined', () => {
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+          }));
+
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('content and type is undefined', () => {
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+          }));
+
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, '');
+      });
+    });
+  });
+
+  test('_showAlert', done => {
+    element.addEventListener('show-alert', e => {
+      assert.deepEqual(e.detail, {message: 'test message'});
+      assert.isTrue(e.bubbles);
+      done();
+    });
+
+    element._showAlert('test message');
+  });
+
+  test('_viewEditInChangeView', () => {
+    element._change = {};
+    navigateStub.restore();
+    const navStub = sinon.stub(GerritNav, 'navigateToChange');
+    element._patchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+    element._viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1], undefined);
+    assert.equal(navStub.lastCall.args[3], true);
+  });
+
+  suite('keyboard shortcuts', () => {
+    // Used as the spy on the handler for each entry in keyBindings.
+    let handleSpy;
+
+    suite('_handleSaveShortcut', () => {
+      let saveStub;
+      setup(() => {
+        handleSpy = sinon.spy(element, '_handleSaveShortcut');
+        saveStub = sinon.stub(element, '_saveEdit');
+      });
+
+      test('save enabled', () => {
+        element._content = '';
+        element._newContent = '_test';
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flush();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isTrue(saveStub.calledOnce);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flush();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.equal(saveStub.callCount, 2);
+      });
+
+      test('save disabled', () => {
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flush();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isFalse(saveStub.called);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flush();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.isFalse(saveStub.called);
+      });
+    });
+  });
+
+  suite('gr-storage caching', () => {
+    test('local edit exists', () => {
+      sinon.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'pending edit'});
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'old content',
+          }));
+
+      const alertStub = sinon.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element._getFileData(1, 'test', 1).then(() => {
+        flush();
+
+        assert.isTrue(alertStub.called);
+        assert.equal(element._newContent, 'pending edit');
+        assert.equal(element._content, 'old content');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('local edit exists, is same as remote edit', () => {
+      sinon.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'pending edit'});
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'pending edit',
+          }));
+
+      const alertStub = sinon.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element._getFileData(1, 'test', 1).then(() => {
+        flush();
+
+        assert.isFalse(alertStub.called);
+        assert.equal(element._newContent, 'pending edit');
+        assert.equal(element._content, 'pending edit');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('storage key computation', () => {
+      element._changeNum = 1;
+      element._patchNum = 1;
+      element._path = 'test';
+      assert.equal(element.storageKey, 'c1_ps1_test');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/font-roboto-local-loader.js b/polygerrit-ui/app/elements/font-roboto-local-loader.js
deleted file mode 100644
index 7000d13..0000000
--- a/polygerrit-ui/app/elements/font-roboto-local-loader.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// Place all code related to font-roboto-local here
-import '@polymer/font-roboto-local/roboto.js';
-
diff --git a/polygerrit-ui/app/elements/font-roboto-local-loader.ts b/polygerrit-ui/app/elements/font-roboto-local-loader.ts
new file mode 100644
index 0000000..1be72d2
--- /dev/null
+++ b/polygerrit-ui/app/elements/font-roboto-local-loader.ts
@@ -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.
+ */
+
+// Place all code related to font-roboto-local here
+import '@polymer/font-roboto-local/roboto';
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
deleted file mode 100644
index 6d232f8..0000000
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ /dev/null
@@ -1,582 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../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';
-
-/**
- * @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 {
-    /**
-     * @type {{ query: string, view: string, screen: string }}
-     */
-      params: Object,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-
-      _account: {
-        type: Object,
-        observer: '_accountChanged',
-      },
-
-      /**
-       * The last time the g key was pressed in milliseconds (or a keydown event
-       * was handled if the key is held down).
-       *
-       * @type {number|null}
-       */
-      _lastGKeyPressTimestamp: {
-        type: Number,
-        value: null,
-      },
-
-      /**
-       * @type {{ plugin: Object }}
-       */
-      _serverConfig: Object,
-      _version: String,
-      _showChangeListView: Boolean,
-      _showDashboardView: Boolean,
-      _showChangeView: Boolean,
-      _showDiffView: Boolean,
-      _showSettingsView: Boolean,
-      _showAdminView: Boolean,
-      _showCLAView: Boolean,
-      _showEditorView: Boolean,
-      _showPluginScreen: Boolean,
-      _showDocumentationSearch: Boolean,
-      /** @type {?} */
-      _viewState: Object,
-      /** @type {?} */
-      _lastError: Object,
-      _lastSearchPage: String,
-      _path: String,
-      _pluginScreenName: {
-        type: String,
-        computed: '_computePluginScreenName(params)',
-      },
-      _settingsUrl: String,
-      _feedbackUrl: String,
-      // Used to allow searching on mobile
-      mobileSearch: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Other elements in app must open this URL when
-       * user login is required.
-       */
-      _loginUrl: {
-        type: String,
-        value: '/login',
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_viewChanged(params.view)',
-      '_paramsChanged(params.*)',
-    ];
-  }
-
-  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));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._updateLoginUrl();
-    this.$.reporting.appStarted();
-    this.$.router.start();
-
-    this.$.restAPI.getAccount().then(account => {
-      this._account = account;
-    });
-    this.$.restAPI.getConfig().then(config => {
-      this._serverConfig = config;
-
-      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.$.header.unfloat();
-  }
-
-  _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',
-    });
-  }
-
-  _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._lastError = err;
-    } else {
-      err.emoji = 'o_O';
-      response.text().then(text => {
-        err.moreInfo = text;
-        this._lastError = err;
-      });
-    }
-  }
-
-  _handleLocationChange(e) {
-    this._updateLoginUrl();
-
-    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);
-  }
-
-  _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);
-    }
-  }
-
-  _paramsChanged(paramsRecord) {
-    const params = paramsRecord.base;
-    const viewsToCheck = [GerritNav.View.SEARCH, GerritNav.View.DASHBOARD];
-    if (viewsToCheck.includes(params.view)) {
-      this.set('_lastSearchPage', location.pathname);
-    }
-  }
-
-  _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();
-  }
-
-  _handleKeyboardShortcutDialogClose() {
-    this.$.keyboardShortcuts.close();
-  }
-
-  _handleAccountDetailUpdate(e) {
-    this.$.mainHeader.reload();
-    if (this.params.view === GerritNav.View.SETTINGS) {
-      this.shadowRoot.querySelector('gr-settings-view').reloadAccountDetail();
-    }
-  }
-
-  _handleRegistrationDialogClose(e) {
-    this.params.justRegistered = false;
-    this.$.registrationOverlay.close();
-  }
-
-  _goToOpenedChanges() {
-    GerritNav.navigateToStatusSearch('open');
-  }
-
-  _goToUserDashboard() {
-    GerritNav.navigateToUserDashboard();
-  }
-
-  _goToMergedChanges() {
-    GerritNav.navigateToStatusSearch('merged');
-  }
-
-  _goToAbandonedChanges() {
-    GerritNav.navigateToStatusSearch('abandoned');
-  }
-
-  _goToWatchedChanges() {
-    // The query is hardcoded, and doesn't respect custom menu entries
-    GerritNav.navigateToSearchQuery('is:watched is:open');
-  }
-
-  _computePluginScreenName({plugin, screen}) {
-    if (!plugin || !screen) return '';
-    return `${plugin}-screen-${screen}`;
-  }
-
-  _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();
-  }
-
-  /**
-   * 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);
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/gr-app-element.ts
new file mode 100644
index 0000000..c2fb124
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -0,0 +1,707 @@
+/**
+ * @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 '../styles/shared-styles';
+import '../styles/themes/app-theme';
+import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme';
+import './admin/gr-admin-view/gr-admin-view';
+import './documentation/gr-documentation-search/gr-documentation-search';
+import './change-list/gr-change-list-view/gr-change-list-view';
+import './change-list/gr-dashboard-view/gr-dashboard-view';
+import './change/gr-change-view/gr-change-view';
+import './core/gr-error-manager/gr-error-manager';
+import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog';
+import './core/gr-main-header/gr-main-header';
+import './core/gr-router/gr-router';
+import './core/gr-smart-search/gr-smart-search';
+import './diff/gr-diff-view/gr-diff-view';
+import './edit/gr-editor-view/gr-editor-view';
+import './plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import './plugins/gr-endpoint-param/gr-endpoint-param';
+import './plugins/gr-endpoint-slot/gr-endpoint-slot';
+import './plugins/gr-external-style/gr-external-style';
+import './plugins/gr-plugin-host/gr-plugin-host';
+import './settings/gr-cla-view/gr-cla-view';
+import './settings/gr-registration-dialog/gr-registration-dialog';
+import './settings/gr-settings-view/gr-settings-view';
+import './shared/gr-lib-loader/gr-lib-loader';
+import './shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-app-element_html';
+import {getBaseUrl} from '../utils/url-util';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  SPECIAL_SHORTCUT,
+} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GerritNav, GerritView} from './core/gr-navigation/gr-navigation';
+import {appContext} from '../services/app-context';
+import {flush} from '@polymer/polymer/lib/utils/flush';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
+import {GrRouter} from './core/gr-router/gr-router';
+import {
+  AccountDetailInfo,
+  ElementPropertyDeepChange,
+  ServerInfo,
+} from '../types/common';
+import {GrErrorManager} from './core/gr-error-manager/gr-error-manager';
+import {GrOverlay} from './shared/gr-overlay/gr-overlay';
+import {GrRegistrationDialog} from './settings/gr-registration-dialog/gr-registration-dialog';
+import {
+  AppElementJustRegisteredParams,
+  AppElementParams,
+  isAppElementJustRegisteredParams,
+} from './gr-app-types';
+import {GrMainHeader} from './core/gr-main-header/gr-main-header';
+import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
+import {
+  CustomKeyboardEvent,
+  LocationChangeEvent,
+  PageErrorEventDetail,
+  RpcLogEvent,
+  ShortcutTriggeredEvent,
+  TitleChangeEventDetail,
+} from '../types/events';
+import {ViewState} from '../types/types';
+
+interface ErrorInfo {
+  text: string;
+  emoji?: string;
+  moreInfo?: string;
+}
+
+export interface GrAppElement {
+  $: {
+    restAPI: RestApiService & Element;
+    router: GrRouter;
+    errorManager: GrErrorManager;
+    errorView: HTMLDivElement;
+    mainHeader: GrMainHeader;
+  };
+}
+
+// TODO(TS): implement AppElement interface from gr-app-types.ts
+@customElement('gr-app-element')
+export class GrAppElement extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the URL location changes.
+   *
+   * @event location-change
+   */
+
+  @property({type: Object})
+  params?: AppElementParams;
+
+  @property({type: Object})
+  keyEventTarget = document.body;
+
+  @property({type: Object, observer: '_accountChanged'})
+  _account?: AccountDetailInfo;
+
+  @property({type: Number})
+  _lastGKeyPressTimestamp: number | null = null;
+
+  @property({type: Object})
+  _serverConfig?: ServerInfo;
+
+  @property({type: String})
+  _version?: string;
+
+  @property({type: Boolean})
+  _showChangeListView?: boolean;
+
+  @property({type: Boolean})
+  _showDashboardView?: boolean;
+
+  @property({type: Boolean})
+  _showChangeView?: boolean;
+
+  @property({type: Boolean})
+  _showDiffView?: boolean;
+
+  @property({type: Boolean})
+  _showSettingsView?: boolean;
+
+  @property({type: Boolean})
+  _showAdminView?: boolean;
+
+  @property({type: Boolean})
+  _showCLAView?: boolean;
+
+  @property({type: Boolean})
+  _showEditorView?: boolean;
+
+  @property({type: Boolean})
+  _showPluginScreen?: boolean;
+
+  @property({type: Boolean})
+  _showDocumentationSearch?: boolean;
+
+  @property({type: Object})
+  _viewState?: ViewState;
+
+  @property({type: Object})
+  _lastError?: ErrorInfo;
+
+  @property({type: String})
+  _lastSearchPage?: string;
+
+  @property({type: String})
+  _path?: string;
+
+  @property({type: String, computed: '_computePluginScreenName(params)'})
+  _pluginScreenName?: string;
+
+  @property({type: String})
+  _settingsUrl?: string;
+
+  @property({type: String})
+  _feedbackUrl?: string;
+
+  @property({type: Boolean})
+  mobileSearch = false;
+
+  @property({type: String})
+  _loginUrl = '/login';
+
+  @property({type: Boolean})
+  loadRegistrationDialog = false;
+
+  @property({type: Boolean})
+  loadKeyboardShortcutsDialog = false;
+
+  private reporting = appContext.reportingService;
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
+      [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
+      [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
+      [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
+      [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
+      [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)
+    );
+    // Ideally individual views should handle this event and respond with a soft
+    // reload. This is a catch-all for all views that cannot or have not
+    // implemented that.
+    this.addEventListener('reload', () => window.location.reload());
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._updateLoginUrl();
+    this.reporting.appStarted();
+    this.$.router.start();
+
+    this.$.restAPI.getAccount().then(account => {
+      this._account = account;
+      const role = account ? 'user' : 'guest';
+      this.reporting.reportLifeCycle(`Started as ${role}`);
+    });
+    this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
+
+      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')) {
+      applyDarkTheme();
+    }
+
+    // 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,
+        showDownloadDialog: false,
+        diffMode: null,
+        numFilesShown: null,
+        scrollTop: 0,
+      },
+      changeListView: {
+        query: null,
+        offset: 0,
+        selectedChangeIndex: 0,
+      },
+      dashboardView: {
+        selectedChangeIndex: 0,
+      },
+    };
+  }
+
+  _bindKeyboardShortcuts() {
+    this.bindShortcut(
+      Shortcut.SEND_REPLY,
+      SPECIAL_SHORTCUT.DOC_ONLY,
+      'ctrl+enter',
+      'meta+enter'
+    );
+    this.bindShortcut(Shortcut.EMOJI_DROPDOWN, SPECIAL_SHORTCUT.DOC_ONLY, ':');
+
+    this.bindShortcut(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
+    this.bindShortcut(
+      Shortcut.GO_TO_USER_DASHBOARD,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'i'
+    );
+    this.bindShortcut(
+      Shortcut.GO_TO_OPENED_CHANGES,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'o'
+    );
+    this.bindShortcut(
+      Shortcut.GO_TO_MERGED_CHANGES,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'm'
+    );
+    this.bindShortcut(
+      Shortcut.GO_TO_ABANDONED_CHANGES,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'a'
+    );
+    this.bindShortcut(
+      Shortcut.GO_TO_WATCHED_CHANGES,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'w'
+    );
+
+    this.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
+    this.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
+    this.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
+    this.bindShortcut(Shortcut.NEXT_PAGE, 'n', ']');
+    this.bindShortcut(Shortcut.PREV_PAGE, 'p', '[');
+    this.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
+    this.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's:keydown');
+    this.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
+    this.bindShortcut(Shortcut.EDIT_TOPIC, 't');
+
+    this.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
+    this.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
+    this.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
+    this.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+    this.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
+    this.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
+    this.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
+    this.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
+    this.bindShortcut(
+      Shortcut.DIFF_AGAINST_BASE,
+      SPECIAL_SHORTCUT.V_KEY,
+      'down',
+      's'
+    );
+    // this keyboard shortcut is used in toast _displayDiffAgainstLatestToast
+    // in gr-diff-view. Any updates here should be reflected there
+    this.bindShortcut(
+      Shortcut.DIFF_AGAINST_LATEST,
+      SPECIAL_SHORTCUT.V_KEY,
+      'up',
+      'w'
+    );
+    // this keyboard shortcut is used in toast _displayDiffBaseAgainstLeftToast
+    // in gr-diff-view. Any updates here should be reflected there
+    this.bindShortcut(
+      Shortcut.DIFF_BASE_AGAINST_LEFT,
+      SPECIAL_SHORTCUT.V_KEY,
+      'left',
+      'a'
+    );
+    this.bindShortcut(
+      Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+      SPECIAL_SHORTCUT.V_KEY,
+      'right',
+      'd'
+    );
+    this.bindShortcut(
+      Shortcut.DIFF_BASE_AGAINST_LATEST,
+      SPECIAL_SHORTCUT.V_KEY,
+      'b'
+    );
+
+    this.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
+    this.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
+    if (this._isCursorManagerSupportMoveToVisibleLine()) {
+      this.bindShortcut(Shortcut.VISIBLE_LINE, '.');
+    }
+    this.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
+    this.bindShortcut(Shortcut.PREV_CHUNK, 'p');
+    this.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+    this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+    this.bindShortcut(
+      Shortcut.EXPAND_ALL_COMMENT_THREADS,
+      SPECIAL_SHORTCUT.DOC_ONLY,
+      'e'
+    );
+    this.bindShortcut(
+      Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+      SPECIAL_SHORTCUT.DOC_ONLY,
+      'shift+e'
+    );
+    this.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
+    this.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
+    this.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+    this.bindShortcut(Shortcut.NEW_COMMENT, 'c');
+    this.bindShortcut(
+      Shortcut.SAVE_COMMENT,
+      'ctrl+enter',
+      'meta+enter',
+      'ctrl+s',
+      'meta+s'
+    );
+    this.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
+    this.bindShortcut(Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
+
+    this.bindShortcut(Shortcut.NEXT_FILE, ']');
+    this.bindShortcut(Shortcut.PREV_FILE, '[');
+    this.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+    this.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+    this.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+    this.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+    this.bindShortcut(Shortcut.OPEN_FILE, 'o', 'enter');
+    this.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
+    this.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+    this.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+    this.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+    this.bindShortcut(Shortcut.TOGGLE_BLAME, 'b:keyup');
+    this.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
+    this.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
+
+    this.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
+    this.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
+
+    this.bindShortcut(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?: AccountDetailInfo) {
+    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;
+  }
+
+  @observe('params.view')
+  _viewChanged(view?: GerritView) {
+    this.$.errorView.classList.remove('show');
+    this.set('_showChangeListView', view === GerritView.SEARCH);
+    this.set('_showDashboardView', view === GerritView.DASHBOARD);
+    this.set('_showChangeView', view === GerritView.CHANGE);
+    this.set('_showDiffView', view === GerritView.DIFF);
+    this.set('_showSettingsView', view === GerritView.SETTINGS);
+    // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
+    this.set(
+      '_showAdminView',
+      view === GerritView.ADMIN ||
+        view === GerritView.GROUP ||
+        view === GerritView.REPO
+    );
+    this.set('_showCLAView', view === GerritView.AGREEMENTS);
+    this.set('_showEditorView', view === GerritView.EDIT);
+    const isPluginScreen = view === GerritView.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 === GerritView.DOCUMENTATION_SEARCH
+    );
+    if (
+      this.params &&
+      isAppElementJustRegisteredParams(this.params) &&
+      this.params.justRegistered
+    ) {
+      this.loadRegistrationDialog = true;
+      flush();
+      const registrationOverlay = this.shadowRoot!.querySelector(
+        '#registrationOverlay'
+      ) as GrOverlay;
+      const registrationDialog = this.shadowRoot!.querySelector(
+        '#registrationDialog'
+      ) as GrRegistrationDialog;
+      registrationOverlay.open();
+      registrationDialog.loadData().then(() => {
+        registrationOverlay.refit();
+      });
+    }
+  }
+
+  _handleShortcutTriggered(event: ShortcutTriggeredEvent) {
+    const {event: e, goKey, vKey} = event.detail;
+    // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
+    let key = `${((e as unknown) as KeyboardEvent).key}:${e.type}`;
+    if (goKey) key = 'g+' + key;
+    if (vKey) key = 'v+' + 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] as Element).nodeName) ??
+        'unknown',
+    });
+  }
+
+  _handlePageError(e: CustomEvent<PageErrorEventDetail>) {
+    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: ErrorInfo = {
+      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;
+      });
+    }
+  }
+
+  _handleLocationChange(e: LocationChangeEvent) {
+    this._updateLoginUrl();
+
+    const hash = e.detail.hash.substring(1);
+    let pathname = e.detail.pathname;
+    if (pathname.startsWith('/c/') && Number(hash) > 0) {
+      pathname += '@' + hash;
+    }
+    this.set('_path', pathname);
+  }
+
+  _updateLoginUrl() {
+    const baseUrl = getBaseUrl();
+    if (baseUrl) {
+      // Strip the canonical path from the path since needing canonical in
+      // the path is unneeded 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
+        );
+    }
+  }
+
+  @observe('params.*')
+  _paramsChanged(
+    paramsRecord: ElementPropertyDeepChange<GrAppElement, 'params'>
+  ) {
+    const params = paramsRecord.base;
+    const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD];
+    if (params?.view && viewsToCheck.includes(params.view)) {
+      this.set('_lastSearchPage', location.pathname);
+    }
+  }
+
+  _handleTitleChange(e: CustomEvent<TitleChangeEventDetail>) {
+    if (e.detail.title) {
+      document.title = e.detail.title + ' · Gerrit Code Review';
+    } else {
+      document.title = '';
+    }
+  }
+
+  handleShowKeyboardShortcuts() {
+    this.loadKeyboardShortcutsDialog = true;
+    flush();
+    (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open();
+  }
+
+  _showKeyboardShortcuts(e: CustomKeyboardEvent) {
+    // same shortcut should close the dialog if pressed again
+    // when dialog is open
+    this.loadKeyboardShortcutsDialog = true;
+    flush();
+    const keyboardShortcuts = this.shadowRoot!.querySelector(
+      '#keyboardShortcuts'
+    ) as GrOverlay;
+    if (!keyboardShortcuts) return;
+    if (keyboardShortcuts.opened) {
+      keyboardShortcuts.close();
+      return;
+    }
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    keyboardShortcuts.open();
+  }
+
+  _handleKeyboardShortcutDialogClose() {
+    (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).close();
+  }
+
+  _handleAccountDetailUpdate() {
+    this.$.mainHeader.reload();
+    if (this.params?.view === GerritView.SETTINGS) {
+      (this.shadowRoot!.querySelector(
+        'gr-settings-view'
+      ) as GrSettingsView).reloadAccountDetail();
+    }
+  }
+
+  _handleRegistrationDialogClose() {
+    // The registration dialog is visible only if this.params is
+    // instanceof AppElementJustRegisteredParams
+    (this.params as AppElementJustRegisteredParams).justRegistered = false;
+    (this.shadowRoot!.querySelector(
+      '#registrationOverlay'
+    ) as GrOverlay).close();
+  }
+
+  _goToOpenedChanges() {
+    GerritNav.navigateToStatusSearch('open');
+  }
+
+  _goToUserDashboard() {
+    GerritNav.navigateToUserDashboard();
+  }
+
+  _goToMergedChanges() {
+    GerritNav.navigateToStatusSearch('merged');
+  }
+
+  _goToAbandonedChanges() {
+    GerritNav.navigateToStatusSearch('abandoned');
+  }
+
+  _goToWatchedChanges() {
+    // The query is hardcoded, and doesn't respect custom menu entries
+    GerritNav.navigateToSearchQuery('is:watched is:open');
+  }
+
+  _computePluginScreenName(params: AppElementParams) {
+    if (params.view !== GerritView.PLUGIN_SCREEN) return '';
+    if (!params.plugin || !params.screen) return '';
+    return `${params.plugin}-screen-${params.screen}`;
+  }
+
+  _logWelcome() {
+    console.group('Runtime Info');
+    console.info('Gerrit UI (PolyGerrit)');
+    console.info(`Gerrit Server Version: ${this._version}`);
+    if (window.VERSION_INFO) {
+      console.info(`UI Version Info: ${window.VERSION_INFO}`);
+    }
+    if (this._feedbackUrl) {
+      console.info(`Please file bugs and feedback at: ${this._feedbackUrl}`);
+    }
+    console.groupEnd();
+  }
+
+  /**
+   * 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: RpcLogEvent) {
+    this.reporting.reportRpcTiming(e.detail.anonymizedUrl, e.detail.elapsed);
+  }
+
+  _mobileSearchToggle() {
+    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';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-app-element': GrAppElement;
+  }
+}
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.js b/polygerrit-ui/app/elements/gr-app-element_html.js
deleted file mode 100644
index 2ee48c1..0000000
--- a/polygerrit-ui/app/elements/gr-app-element_html.js
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
new file mode 100644
index 0000000..c1258fb
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element_html.ts
@@ -0,0 +1,238 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--background-color-tertiary);
+      display: flex;
+      flex-direction: column;
+      min-height: 100%;
+    }
+    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);
+      /* Make sure the header is above the main content, to preserve box-shadow
+         visibility. We need 2 here instead of 1, because dropdowns in the
+         header should be shown on top of the sticky diff header, which has a
+         z-index of 1. */
+      z-index: 2;
+    }
+    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-main-header
+    id="mainHeader"
+    search-query="{{params.query}}"
+    on-mobile-search="_mobileSearchToggle"
+    on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
+    mobile-search-hidden="[[!mobileSearch]]"
+    login-url="[[_loginUrl]]"
+  >
+  </gr-main-header>
+  <main>
+    <template is="dom-if" if="[[mobileSearch]]">
+      <gr-smart-search
+        id="search"
+        label="Search for changes"
+        search-query="{{params.query}}"
+        hidden="[[!mobileSearch]]"
+      >
+      </gr-smart-search>
+    </template>
+    <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>
+  <template is="dom-if" if="[[loadKeyboardShortcutsDialog]]">
+    <gr-overlay id="keyboardShortcuts" with-backdrop="">
+      <gr-keyboard-shortcuts-dialog
+        on-close="_handleKeyboardShortcutDialogClose"
+      ></gr-keyboard-shortcuts-dialog>
+    </gr-overlay>
+  </template>
+  <template is="dom-if" if="[[loadRegistrationDialog]]">
+    <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>
+  </template>
+  <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-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
deleted file mode 100644
index f0a9131..0000000
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @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-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
new file mode 100644
index 0000000..fac5f45
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -0,0 +1,180 @@
+/**
+ * @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 {
+  getAccountDisplayName,
+  getDisplayName,
+  getGroupDisplayName,
+  getUserName,
+} from '../utils/display-name-util';
+import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
+import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper';
+import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from './diff/gr-diff/gr-diff-group';
+import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder';
+import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side';
+import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image';
+import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified';
+import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary';
+import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api';
+import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api';
+import {GrEditConstants} from './edit/gr-edit-constants';
+import {
+  GrDomHooksManager,
+  GrDomHook,
+} from './plugins/gr-dom-hooks/gr-dom-hooks';
+import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator';
+import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api';
+import {
+  SiteBasedCache,
+  FetchPromisesCache,
+  GrRestApiHelper,
+} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser';
+import {
+  getPluginEndpoints,
+  GrPluginEndpoints,
+} from './shared/gr-js-api-interface/gr-plugin-endpoints';
+import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface';
+import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter';
+import {
+  GrReviewerSuggestionsProvider,
+  SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {util} from '../scripts/util';
+import {page} from '../utils/page-wrapper-utils';
+import {appContext} from '../services/app-context';
+import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api';
+import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context';
+import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api';
+import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api';
+import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider';
+import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider';
+import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper';
+import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api';
+import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api';
+import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api';
+import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api';
+import {
+  getPluginLoader,
+  PluginLoader,
+} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
+import {
+  getPluginNameFromUrl,
+  getRestAPI,
+  PLUGIN_LOADING_TIMEOUT_MS,
+  PRELOADED_PROTOCOL,
+  send,
+} from './shared/gr-js-api-interface/gr-api-utils';
+import {getBaseUrl} from '../utils/url-util';
+import {GerritNav} from './core/gr-navigation/gr-navigation';
+import {getRootElement} from '../scripts/rootElement';
+import {rangesEqual} from './diff/gr-diff/gr-diff-utils';
+import {RevisionInfo} from './shared/revision-info/revision-info';
+import {CoverageType} from '../types/types';
+import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll';
+
+export function initGlobalVariables() {
+  window.GrDisplayNameUtils = {
+    getUserName,
+    getDisplayName,
+    getAccountDisplayName,
+    getGroupDisplayName,
+  };
+  window.GrAnnotation = GrAnnotation;
+  window.GrAttributeHelper = GrAttributeHelper;
+  window.GrDiffLine = GrDiffLine;
+  window.GrDiffLineType = GrDiffLineType;
+  window.GrDiffGroup = GrDiffGroup;
+  window.GrDiffGroupType = GrDiffGroupType;
+  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.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.GrCountStringFormatter = GrCountStringFormatter;
+  window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
+  window.util = util;
+  window.page = page;
+  window.Auth = appContext.authService;
+  window.EventEmitter = appContext.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.Auth = appContext.authService;
+
+  window.Gerrit._pluginLoader = getPluginLoader();
+  // TODO: should define as a getter
+  window.Gerrit._endpoints = getPluginEndpoints();
+
+  // TODO(TS): seems not used, probably just remove
+  window.Gerrit.slotToContent = (slot: any) => 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
deleted file mode 100644
index 14dbd87..0000000
--- a/polygerrit-ui/app/elements/gr-app-init.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {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-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
new file mode 100644
index 0000000..6d79ce1
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-init.ts
@@ -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 {initAppContext} from '../services/app-context-init';
+import {
+  initVisibilityReporter,
+  initPerformanceReporter,
+  initErrorReporter,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {appContext} from '../services/app-context';
+
+interface UninitializedPolymer {
+  lazyRegister: boolean;
+}
+
+if (!window.Polymer) {
+  // Without as... it violates internal google rules.
+  ((window.Polymer as unknown) as UninitializedPolymer) = {
+    lazyRegister: true,
+  };
+}
+
+initAppContext();
+initVisibilityReporter(appContext);
+initPerformanceReporter(appContext);
+initErrorReporter(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
new file mode 100644
index 0000000..b05117f
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -0,0 +1,146 @@
+/**
+ * @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 {
+  GenerateUrlParameters,
+  GerritView,
+  GroupDetailView,
+  RepoDetailView,
+} from './core/gr-navigation/gr-navigation';
+import {
+  DashboardId,
+  GroupId,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  UrlEncodedCommentId,
+} from '../types/common';
+
+export interface AppElement extends HTMLElement {
+  params: AppElementParams | GenerateUrlParameters;
+}
+
+// TODO(TS): Remove unify AppElementParams with GenerateUrlParameters
+// Seems we can use GenerateUrlParameters instead of AppElementParams,
+// but it require some refactoring
+export interface AppElementDashboardParams {
+  view: GerritView.DASHBOARD;
+  project?: RepoName;
+  dashboard: DashboardId;
+  user?: string;
+  sections: Array<{name: string; query: string}>;
+  title?: string;
+}
+
+export interface AppElementGroupParams {
+  view: GerritView.GROUP;
+  detail?: GroupDetailView;
+  groupId: GroupId;
+}
+
+export interface AppElementAdminParams {
+  view: GerritView.ADMIN;
+  adminView: string;
+  offset?: string | number;
+  filter?: string | null;
+  openCreateModal?: boolean;
+}
+
+export interface AppElementRepoParams {
+  view: GerritView.REPO;
+  detail?: RepoDetailView;
+  repo: RepoName;
+  offset?: string | number;
+  filter?: string | null;
+}
+
+export interface AppElementDocSearchParams {
+  view: GerritView.DOCUMENTATION_SEARCH;
+  filter: string | null;
+}
+
+export interface AppElementPluginScreenParams {
+  view: GerritView.PLUGIN_SCREEN;
+  plugin?: string;
+  screen?: string;
+}
+
+export interface AppElementSearchParam {
+  view: GerritView.SEARCH;
+  query: string;
+  offset: string;
+}
+
+export interface AppElementSettingsParam {
+  view: GerritView.SETTINGS;
+  emailToken?: string;
+}
+
+export interface AppElementAgreementParam {
+  view: GerritView.AGREEMENTS;
+}
+
+export interface AppElementDiffViewParam {
+  view: GerritView.DIFF;
+  changeNum: NumericChangeId;
+  project?: RepoName;
+  commentId?: UrlEncodedCommentId;
+  path?: string;
+  patchNum?: PatchSetNum;
+  basePatchNum?: PatchSetNum;
+  lineNum: number;
+  leftSide?: boolean;
+  commentLink?: boolean;
+}
+export interface AppElementChangeViewParams {
+  view: GerritView.CHANGE;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  edit?: boolean;
+  patchNum?: PatchSetNum;
+  basePatchNum?: PatchSetNum;
+  queryMap?: Map<string, string> | URLSearchParams;
+}
+
+export interface AppElementJustRegisteredParams {
+  // We use params.view === ... as a type guard.
+  // The view?: never tells to the compiler that
+  // AppElementJustRegisteredParams can't have view property.
+  // Otherwise, the compiler reports an error when the code tries to use
+  // the property 'view' of AppElementParams.
+  view?: never;
+  justRegistered: boolean;
+}
+
+export type AppElementParams =
+  | AppElementDashboardParams
+  | AppElementGroupParams
+  | AppElementAdminParams
+  | AppElementChangeViewParams
+  | AppElementRepoParams
+  | AppElementDocSearchParams
+  | AppElementPluginScreenParams
+  | AppElementSearchParam
+  | AppElementSettingsParam
+  | AppElementAgreementParam
+  | AppElementDiffViewParam
+  | AppElementJustRegisteredParams;
+
+export function isAppElementJustRegisteredParams(
+  p: AppElementParams
+): p is AppElementJustRegisteredParams {
+  return (p as AppElementJustRegisteredParams).justRegistered !== undefined;
+}
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
deleted file mode 100644
index 1483f7a..0000000
--- a/polygerrit-ui/app/elements/gr-app.html
+++ /dev/null
@@ -1 +0,0 @@
-<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
deleted file mode 100644
index 6bc79f7a..0000000
--- a/polygerrit-ui/app/elements/gr-app.js
+++ /dev/null
@@ -1,66 +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.
- */
-
-/*
-  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.ts b/polygerrit-ui/app/elements/gr-app.ts
new file mode 100644
index 0000000..f19931f
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {safeTypesBridge} from '../utils/safe-types-util';
+import './gr-app-init';
+import './font-roboto-local-loader';
+// Sets up global Polymer variable, because plugins requires it.
+import '../scripts/bundled-polymer';
+
+/**
+ * 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';
+setCancelSyntheticClickEvents(false);
+setPassiveTouchGestures(true);
+
+import {initGlobalVariables} from './gr-app-global-var-init';
+import './gr-app-element';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-app_html';
+import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
+import {customElement} from '@polymer/decorators';
+import {installPolymerResin} from '../scripts/polymer-resin-install';
+
+installPolymerResin(safeTypesBridge);
+
+@customElement('gr-app')
+class GrApp extends GestureEventListeners(LegacyElementMixin(PolymerElement)) {
+  static get template() {
+    return htmlTemplate;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-app': GrApp;
+  }
+}
+
+initGlobalVariables();
+initGerritPluginApi();
diff --git a/polygerrit-ui/app/elements/gr-app_html.js b/polygerrit-ui/app/elements/gr-app_html.js
deleted file mode 100644
index 3da1b69..0000000
--- a/polygerrit-ui/app/elements/gr-app_html.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-app-element id="app-element"></gr-app-element>
-`;
diff --git a/polygerrit-ui/app/elements/gr-app_html.ts b/polygerrit-ui/app/elements/gr-app_html.ts
new file mode 100644
index 0000000..f6172c9
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 6a13789..0000000
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ /dev/null
@@ -1,107 +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">
-<meta charset="utf-8">
-<title>gr-app</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-app id="app"></gr-app>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../test/common-test-setup.js';
-import './gr-app.js';
-import {GerritNav} from './core/gr-navigation/gr-navigation.js';
-
-suite('gr-app 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-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); },
-    });
-
-    element = fixture('basic');
-    flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  const appElement = () => element.$['app-element'];
-
-  test('reporting', () => {
-    assert.isTrue(appElement().$.reporting.appStarted.calledOnce);
-  });
-
-  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('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/gr-app_test.js b/polygerrit-ui/app/elements/gr-app_test.js
new file mode 100644
index 0000000..4c5e965
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app_test.js
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import './gr-app.js';
+import {appContext} from '../services/app-context.js';
+import {GerritNav} from './core/gr-navigation/gr-navigation.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`<gr-app id="app"></gr-app>`);
+
+suite('gr-app tests', () => {
+  let element;
+
+  setup(done => {
+    sinon.stub(appContext.reportingService, 'appStarted');
+    stub('gr-account-dropdown', {
+      _getTopContent: sinon.stub(),
+    });
+    stub('gr-router', {
+      start: sinon.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); },
+    });
+
+    element = basicFixture.instantiate();
+    flush(done);
+  });
+
+  const appElement = () => element.$['app-element'];
+
+  test('reporting', () => {
+    assert.isTrue(appElement().reporting.appStarted.calledOnce);
+  });
+
+  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('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);
+  });
+});
+
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
deleted file mode 100644
index 2dfb79f..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
+++ /dev/null
@@ -1,35 +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.
- */
-
-/** @constructor */
-export 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});
-};
-
-GrAdminApi.prototype.getMenuLinks = function() {
-  return this._menuLinks.slice(0);
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
new file mode 100644
index 0000000..1332118
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -0,0 +1,47 @@
+/**
+ * @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.
+ */
+
+import {PluginApi} from '../gr-plugin-types';
+
+/** Interface for menu link */
+export interface MenuLink {
+  text: string;
+  url: string;
+  capability: string | null;
+}
+
+/**
+ * GrAdminApi class.
+ *
+ * Defines common methods to register / retrieve menu links.
+ */
+export class GrAdminApi {
+  // TODO(TS): maybe define as enum if its a limited set
+  private menuLinks: MenuLink[] = [];
+
+  constructor(private readonly plugin: PluginApi) {
+    this.plugin.on('admin-menu-links', this);
+  }
+
+  addMenuLink(text: string, url: string, capability?: string) {
+    this.menuLinks.push({text, url, capability: capability || null});
+  }
+
+  getMenuLinks(): MenuLink[] {
+    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
deleted file mode 100644
index a865233..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
+++ /dev/null
@@ -1,72 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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 '../../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';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-admin-api tests', () => {
-  let sandbox;
-  let adminApi;
-
-  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-admin-api/gr-admin-api_test.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
new file mode 100644
index 0000000..9a8f75e
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
@@ -0,0 +1,59 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {getPluginLoader} 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-admin-api tests', () => {
+  let adminApi;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    getPluginLoader().loadPlugins([]);
+    adminApi = plugin.admin();
+  });
+
+  teardown(() => {
+    adminApi = null;
+  });
+
+  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'});
+  });
+});
+
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
deleted file mode 100644
index d5ebb65..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
+++ /dev/null
@@ -1,108 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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);
-  }
-};
-
-/**
- * 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}}));
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
new file mode 100644
index 0000000..6fc7a17
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -0,0 +1,94 @@
+/**
+ * @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 class GrAttributeHelper {
+  private readonly _promises = new Map<string, Promise<any>>();
+
+  // TOOD(TS): Change any to something more like HTMLElement.
+  constructor(public element: any) {}
+
+  _getChangedEventName(name: string): string {
+    return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
+  }
+
+  /**
+   * Returns true if the property is defined on wrapped element.
+   */
+  _elementHasProperty(name: string) {
+    return this.element[name] !== undefined;
+  }
+
+  _reportValue(callback: (value: any) => void, value: any) {
+    try {
+      callback(value);
+    } catch (e) {
+      console.info(e);
+    }
+  }
+
+  /**
+   * Binds callback to property updates.
+   *
+   * @param name Property name.
+   * @return Unbind function.
+   */
+  bind(name: string, callback: (value: any) => void) {
+    const attributeChangedEventName = this._getChangedEventName(name);
+    const changedHandler = (e: CustomEvent) =>
+      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.
+   */
+  get(name: string): Promise<unknown> {
+    if (this._elementHasProperty(name)) {
+      return Promise.resolve(this.element[name]);
+    }
+    if (!this._promises.has(name)) {
+      let resolve: (value: any) => void;
+      const promise = new Promise(r => (resolve = r));
+      const unbind = this.bind(name, value => {
+        resolve(value);
+        unbind();
+      });
+      this._promises.set(name, promise);
+    }
+    return this._promises.get(name)!;
+  }
+
+  /**
+   * Sets value and dispatches event to force notify.
+   */
+  set(name: string, value: any) {
+    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
deleted file mode 100644
index 50f9002..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
+++ /dev/null
@@ -1,101 +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>gr-attribute-helper</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-element id="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,
-    },
-  },
-});
-</script>
-
-</dom-element>
-
-<test-fixture id="basic">
-  <template>
-    <some-element></some-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import {GrAttributeHelper} from './gr-attribute-helper.js';
-
-suite('gr-attribute-helper tests', () => {
-  let element;
-  let instance;
-  let sandbox;
-
-  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-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
new file mode 100644
index 0000000..ea7bdc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -0,0 +1,76 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {GrAttributeHelper} from './gr-attribute-helper.js';
+
+Polymer({
+  is: 'gr-attrubute-helper-some-element',
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
+});
+
+const basicFixture = fixtureFromElement('gr-attrubute-helper-some-element');
+
+suite('gr-attribute-helper tests', () => {
+  let element;
+  let instance;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    instance = new GrAttributeHelper(element);
+  });
+
+  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 = sinon.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);
+  });
+});
+
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
deleted file mode 100644
index 8be50b1..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
+++ /dev/null
@@ -1,44 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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();
-  }
-  this._hook.onAttached(element =>
-    this.plugin.attributeHelper(element).bind('labels', callback));
-  return this;
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
new file mode 100644
index 0000000..322d32e
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
@@ -0,0 +1,42 @@
+/**
+ * @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.
+ */
+import {HookApi, PluginApi} from '../gr-plugin-types';
+
+export class GrChangeMetadataApi {
+  private _hook: HookApi | null;
+
+  public plugin: PluginApi;
+
+  constructor(plugin: PluginApi) {
+    this.plugin = plugin;
+    this._hook = null;
+  }
+
+  _createHook() {
+    this._hook = this.plugin.hook('change-metadata-item');
+  }
+
+  onLabelsChanged(callback: (value: unknown) => void) {
+    if (!this._hook) {
+      this._createHook();
+    }
+    this._hook!.onAttached((element: Element) =>
+      this.plugin.attributeHelper(element).bind('labels', callback)
+    );
+    return this;
+  }
+}
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
deleted file mode 100644
index b998733..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ /dev/null
@@ -1,168 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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.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];
-};
-
-/** @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;
-};
-
-/**
- * Get all DOM hook elements.
- */
-GrDomHook.prototype.getAllAttached = function() {
-  return this._instances;
-};
-
-/**
- * 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;
-};
-
-/**
- * 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;
-};
-
-/**
- * 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',
-    '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.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
new file mode 100644
index 0000000..dd76be4
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -0,0 +1,161 @@
+/**
+ * @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 {PolymerElement} from '@polymer/polymer/polymer-element';
+import {HookApi, HookCallback, PluginApi} from '../gr-plugin-types';
+
+export class GrDomHooksManager {
+  private _hooks: Record<string, GrDomHook>;
+
+  private _plugin: PluginApi;
+
+  constructor(plugin: PluginApi) {
+    this._plugin = plugin;
+    this._hooks = {};
+  }
+
+  _getHookName(endpointName: string, moduleName?: string) {
+    if (moduleName) {
+      return endpointName + ' ' + 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: string =
+        this._plugin.getPluginName() || 'unknown_plugin';
+      return pluginName.toLowerCase() + '-autogenerated-' + endpointName;
+    }
+  }
+
+  getDomHook(endpointName: string, moduleName?: string) {
+    const hookName = this._getHookName(endpointName, moduleName);
+    if (!this._hooks[hookName]) {
+      this._hooks[hookName] = new GrDomHook(hookName, moduleName);
+    }
+    return this._hooks[hookName];
+  }
+}
+
+export class GrDomHook implements HookApi {
+  private _instances: HTMLElement[] = [];
+
+  private _attachCallbacks: HookCallback[] = [];
+
+  private _detachCallbacks: HookCallback[] = [];
+
+  private _moduleName: string;
+
+  private _lastAttachedPromise: Promise<HTMLElement> | null = null;
+
+  constructor(hookName: string, moduleName?: string) {
+    if (moduleName) {
+      this._moduleName = moduleName;
+    } else {
+      this._moduleName = hookName;
+      this._createPlaceholder(hookName);
+    }
+  }
+
+  _createPlaceholder(hookName: string) {
+    class HookPlaceholder extends PolymerElement {
+      static get is() {
+        return hookName;
+      }
+
+      static get properties() {
+        return {
+          plugin: Object,
+          content: Object,
+        };
+      }
+    }
+
+    customElements.define(HookPlaceholder.is, HookPlaceholder);
+  }
+
+  handleInstanceDetached(instance: HTMLElement) {
+    const index = this._instances.indexOf(instance);
+    if (index !== -1) {
+      this._instances.splice(index, 1);
+    }
+    this._detachCallbacks.forEach(callback => callback(instance));
+  }
+
+  handleInstanceAttached(instance: HTMLElement) {
+    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.
+   */
+  getLastAttached(): Promise<HTMLElement> {
+    if (this._instances.length) {
+      return Promise.resolve(this._instances.slice(-1)[0]);
+    }
+    if (!this._lastAttachedPromise) {
+      let resolve: HookCallback;
+      const promise = new Promise<HTMLElement>(r => {
+        resolve = r;
+        this._attachCallbacks.push(resolve);
+      });
+      this._lastAttachedPromise = promise.then((element: HTMLElement) => {
+        this._lastAttachedPromise = null;
+        const index = this._attachCallbacks.indexOf(resolve);
+        if (index !== -1) {
+          this._attachCallbacks.splice(index, 1);
+        }
+        return element;
+      });
+    }
+    return this._lastAttachedPromise;
+  }
+
+  /**
+   * Get all DOM hook elements.
+   */
+  getAllAttached() {
+    return this._instances;
+  }
+
+  /**
+   * Install a new callback to invoke when a new instance of DOM hook element
+   * is attached.
+   */
+  onAttached(callback: HookCallback) {
+    this._attachCallbacks.push(callback);
+    return this;
+  }
+
+  /**
+   * Install a new callback to invoke when an instance of DOM hook element
+   * is detached.
+   *
+   */
+  onDetached(callback: HookCallback) {
+    this._detachCallbacks.push(callback);
+    return this;
+  }
+
+  /**
+   * Name of DOM hook element that will be installed into the endpoint.
+   */
+  getModuleName() {
+    return this._moduleName;
+  }
+}
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
deleted file mode 100644
index 17a22e9..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
+++ /dev/null
@@ -1,166 +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>gr-dom-hooks</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>
-    <div></div>
-  </template>
-</test-fixture>
-
-<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';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-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();
-    });
-
-    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');
-    });
-  });
-
-  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-dom-hooks/gr-dom-hooks_test.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
new file mode 100644
index 0000000..49223b9
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
@@ -0,0 +1,125 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.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';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-dom-hooks tests', () => {
+  let instance;
+  let hook;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrDomHooksManager(plugin);
+  });
+
+  suite('placeholder', () => {
+    setup(()=>{
+      sinon.stub(GrDomHook.prototype, '_createPlaceholder');
+      hook = instance.getDomHook('foo-bar');
+    });
+
+    test('registers placeholder class', () => {
+      assert.isTrue(hook._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');
+    });
+  });
+
+  suite('custom element', () => {
+    setup(() => {
+      hook = instance.getDomHook('foo-bar', 'my-el');
+    });
+
+    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 = sinon.spy();
+      hook.onAttached(onAttachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hook.handleInstanceAttached(el1);
+      hook.handleInstanceAttached(el2);
+      assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
+      assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('onDetached', () => {
+      const onDetachedSpy = sinon.spy();
+      hook.onDetached(onDetachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hook.handleInstanceDetached(el1);
+      assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
+      hook.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';
+      hook.handleInstanceAttached(el1);
+      hook.handleInstanceAttached(el2);
+      assert.deepEqual([el1, el2], hook.getAllAttached());
+      hook.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';
+      hook.handleInstanceAttached(el1);
+      hook.handleInstanceAttached(el2);
+      const afterAttachedPromise = hook.getLastAttached().then(
+          el => assert.strictEqual(el2, el));
+      return Promise.all([
+        beforeAttachedPromise,
+        afterAttachedPromise,
+      ]);
+    });
+  });
+});
+
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
deleted file mode 100644
index b40ea15..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ /dev/null
@@ -1,192 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const INIT_PROPERTIES_TIMEOUT_MS = 10000;
-
-/** @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: {
-        type: Map,
-        value() { return new Map(); },
-      },
-      /**
-       * This map prevents importing the same endpoint twice.
-       * Without caching, if a plugin is loaded after the loaded plugins
-       * callback fires, it will be imported twice and appear twice on the page.
-       *
-       * @type {!Map}
-       */
-      _initializedPlugins: {
-        type: Map,
-        value() { return new Map(); },
-      },
-    };
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    for (const [el, domHook] of this._domHooks) {
-      domHook.handleInstanceDetached(el);
-    }
-    pluginEndpoints.onDetachedEndpoint(this.name, this._endpointCallBack);
-  }
-
-  /**
-   * @suppress {checkTypes}
-   */
-  _import(url) {
-    return new Promise((resolve, reject) => {
-      importHref(url, resolve, reject);
-    });
-  }
-
-  _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));
-  }
-
-  _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 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.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
new file mode 100644
index 0000000..3a83729
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -0,0 +1,200 @@
+/**
+ * @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 '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-endpoint-decorator_html';
+import {
+  getPluginEndpoints,
+  ModuleInfo,
+} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {customElement, property} from '@polymer/decorators';
+import {HookApi, PluginApi} from '../gr-plugin-types';
+
+const INIT_PROPERTIES_TIMEOUT_MS = 10000;
+
+@customElement('gr-endpoint-decorator')
+class GrEndpointDecorator extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  name!: string;
+
+  @property({type: Object})
+  _domHooks = new Map<HTMLElement, HookApi>();
+
+  @property({type: Object})
+  _initializedPlugins = new Map<string, boolean>();
+
+  /**
+   * This is the callback that the plugin endpoint manager should be calling
+   * when a new element is registered for this endpoint. It points to
+   * _initModule().
+   */
+  _endpointCallBack: (info: ModuleInfo) => void = () => {};
+
+  /** @override */
+  detached() {
+    super.detached();
+    for (const [el, domHook] of this._domHooks) {
+      domHook.handleInstanceDetached(el);
+    }
+    getPluginEndpoints().onDetachedEndpoint(this.name, this._endpointCallBack);
+  }
+
+  _initDecoration(
+    name: string,
+    plugin: PluginApi,
+    slot?: string
+  ): Promise<HTMLElement> {
+    const el = document.createElement(name);
+    return this._initProperties(
+      el,
+      plugin,
+      this.getContentChildren().find(el => el.nodeName !== 'GR-ENDPOINT-PARAM')
+    ).then(el => {
+      const slotEl = slot
+        ? this.querySelector(`gr-endpoint-slot[name=${slot}]`)
+        : null;
+      if (slot && slotEl?.parentNode) {
+        slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
+      } else {
+        this._appendChild(el);
+      }
+      return el;
+    });
+  }
+
+  _initReplacement(name: string, plugin: PluginApi): Promise<HTMLElement> {
+    this.getContentChildNodes()
+      .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
+      .forEach(node => (node as ChildNode).remove());
+    const el = document.createElement(name);
+    return this._initProperties(el, plugin).then((el: HTMLElement) =>
+      this._appendChild(el)
+    );
+  }
+
+  _getEndpointParams() {
+    return Array.from(this.querySelectorAll('gr-endpoint-param'));
+  }
+
+  _initProperties(
+    htmlEl: HTMLElement,
+    plugin: PluginApi,
+    content?: HTMLElement
+  ) {
+    const el = htmlEl as HTMLElement & {
+      plugin?: PluginApi;
+      content?: HTMLElement;
+    };
+    el.plugin = plugin;
+    if (content) {
+      el.content = content;
+    }
+    const expectProperties = this._getEndpointParams().map(paramEl => {
+      const helper = plugin.attributeHelper(paramEl);
+      // TODO: this should be replaced by accessing the property directly
+      const paramName = paramEl.getAttribute('name');
+      if (!paramName) throw Error('plugin endpoint parameter missing a name');
+      return helper
+        .get('value')
+        .then(() =>
+          helper.bind('value', value =>
+            plugin.attributeHelper(el).set(paramName, value)
+          )
+        );
+    });
+    // TODO(TS): Should be a number, but TS thinks that is must be some weird
+    // NodeJS.Timeout object.
+    let timeoutId: any;
+    const timeout = new Promise(
+      () =>
+        (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(() => el)
+      .finally(() => {
+        if (timeoutId) clearTimeout(timeoutId);
+      });
+  }
+
+  _appendChild(el: HTMLElement): HTMLElement {
+    if (!this.root) throw Error('plugin endpoint decorator missing root');
+    return this.root.appendChild(el);
+  }
+
+  _initModule({moduleName, plugin, type, domHook, slot}: ModuleInfo) {
+    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) {
+      throw Error(`unknown endpoint type ${type} used by plugin ${name}`);
+    }
+    this._initializedPlugins.set(name, true);
+    initPromise.then(el => {
+      if (domHook) {
+        domHook.handleInstanceAttached(el);
+        this._domHooks.set(el, domHook);
+      }
+    });
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._endpointCallBack = (info: ModuleInfo) => this._initModule(info);
+    getPluginEndpoints().onNewEndpoint(this.name, this._endpointCallBack);
+    if (this.name) {
+      getPluginLoader()
+        .awaitPluginsLoaded()
+        .then(() => getPluginEndpoints().getAndImportPlugins(this.name))
+        .then(() =>
+          getPluginEndpoints()
+            .getDetails(this.name)
+            .forEach(this._initModule, this)
+        );
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-endpoint-decorator': 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
deleted file mode 100644
index c4310fc..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
new file mode 100644
index 0000000..94196df
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 890a457..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ /dev/null
@@ -1,228 +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>gr-endpoint-decorator</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>
-    <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>
-      </gr-endpoint-decorator>
-      <gr-endpoint-decorator name="banana">
-        <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </div>
-  </template>
-</test-fixture>
-
-<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';
-
-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();
-  });
-
-  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 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="banana"]');
-      const 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(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 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(() => {
-        module = Array.from(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 = 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-decorator/gr-endpoint-decorator_test.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
new file mode 100644
index 0000000..c48258d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
@@ -0,0 +1,213 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-endpoint-decorator.js';
+import '../gr-endpoint-param/gr-endpoint-param.js';
+import '../gr-endpoint-slot/gr-endpoint-slot.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+const basicFixture = fixtureFromTemplate(
+    html`<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>
+  </gr-endpoint-decorator>
+  <gr-endpoint-decorator name="banana">
+    <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
+  </gr-endpoint-decorator>
+</div>`
+);
+
+suite('gr-endpoint-decorator', () => {
+  let container;
+
+  let plugin;
+  let decorationHook;
+  let decorationHookWithSlot;
+  let replacementHook;
+
+  setup(done => {
+    resetPlugins();
+    container = basicFixture.instantiate();
+    sinon.stub(getPluginEndpoints(), 'importUrl')
+        .callsFake( url => Promise.resolve());
+    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.
+    getPluginLoader().loadPlugins([]);
+    flush(done);
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('imports plugin-provided modules into endpoints', () => {
+    const endpoints =
+        Array.from(container.querySelectorAll('gr-endpoint-decorator'));
+    assert.equal(endpoints.length, 3);
+    assert.isTrue(getPluginEndpoints().importUrl.calledWith(
+        new URL('http://some/plugin/url.html')
+    ));
+  });
+
+  test('decoration', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="first"]');
+    const modules = Array.from(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 with slot', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="first"]');
+    const modules = [...element.querySelectorAll('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(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="banana"]');
+      const module = Array.from(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(element.root.children).find(
+          element => element.nodeName === 'MOD-ONE');
+      assert.isOk(module1);
+      const module2 = Array.from(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 = element.querySelector('gr-endpoint-param');
+    param['value'] = undefined;
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
+      let module = Array.from(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(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 = 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(element.root.children).find(
+          element => element.nodeName === 'NOOB-NOOB');
+      assert.strictEqual(module['someParam'], value1);
+      param.value = value2;
+      assert.strictEqual(module['someParam'], value2);
+      done();
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
deleted file mode 100644
index 9574391..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
+++ /dev/null
@@ -1,56 +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.
- */
-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';
-
-/** @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}));
-  }
-}
-
-customElements.define(GrEndpointParam.is, GrEndpointParam);
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
new file mode 100644
index 0000000..9e9f348
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -0,0 +1,53 @@
+/**
+ * @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';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-endpoint-param': GrEndpointParam;
+  }
+}
+
+@customElement('gr-endpoint-param')
+export class GrEndpointParam extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  @property({type: String, reflectToAttribute: true})
+  name = '';
+
+  @property({
+    type: Object,
+    notify: true,
+    observer: '_valueChanged',
+  })
+  value?: unknown;
+
+  _valueChanged(value: unknown) {
+    /* 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.
+    */
+    this.dispatchEvent(new CustomEvent('value-changed', {detail: {value}}));
+  }
+}
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
deleted file mode 100644
index 9ee9c3d..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {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-endpoint-slot/gr-endpoint-slot.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
new file mode 100644
index 0000000..4999716
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
@@ -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 {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+
+/**
+ * `gr-endpoint-slot` is used when need control over where
+ * the registered element should appear inside of the endpoint.
+ */
+@customElement('gr-endpoint-slot')
+export class GrEndpointSlot extends PolymerElement {
+  @property({type: String})
+  name!: string;
+}
+
+/**
+ * Mark name as required as `gr-endpoint-slot` without a name
+ * is meaningless.
+ *
+ * This should help catch errors when you assign an element without
+ * name to GrEndpointSlot type.
+ */
+export interface GrEndpointSlot extends PolymerElement {
+  name: string;
+}
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
deleted file mode 100644
index 63d40fc..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
+++ /dev/null
@@ -1,111 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-event-helper">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/** @constructor */
-export function GrEventHelper(element) {
-  this.element = element;
-  this._unsubscribers = [];
-}
-
-/**
- * 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});
-};
-
-/**
- * Alias of onClick
- *
- * @see onClick
- */
-GrEventHelper.prototype.onTap = function(callback) {
-  return this._listen(this.element, callback);
-};
-
-/**
- * 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}`);
-      }
-      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;
-};
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
new file mode 100644
index 0000000..5a4d2ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -0,0 +1,96 @@
+/**
+ * @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 interface ListenOptions {
+  event?: string;
+  capture?: boolean;
+}
+
+export class GrEventHelper {
+  constructor(readonly element: HTMLElement) {}
+
+  /**
+   * Add a callback to arbitrary event.
+   * The callback may return false to prevent event bubbling.
+   */
+  on(event: string, callback: (event: Event) => boolean) {
+    return this._listen(this.element, callback, {event});
+  }
+
+  /**
+   * Alias for @see onClick
+   */
+  onTap(callback: (event: Event) => boolean) {
+    return this.onClick(callback);
+  }
+
+  /**
+   * Add a callback to element click or touch.
+   * The callback may return false to prevent event bubbling.
+   */
+  onClick(callback: (event: Event) => boolean) {
+    return this._listen(this.element, callback);
+  }
+
+  /**
+   * Alias for @see captureClick
+   */
+  captureTap(callback: (event: Event) => boolean) {
+    this.captureClick(callback);
+  }
+
+  /**
+   * 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.
+   */
+  captureClick(callback: (event: Event) => boolean) {
+    const parent = this.element.parentElement!;
+    return this._listen(parent, callback, {capture: true});
+  }
+
+  _listen(
+    container: HTMLElement,
+    callback: (event: Event) => boolean,
+    options?: ListenOptions | null
+  ) {
+    const capture = options?.capture;
+    const event = options?.event || 'click';
+    const handler = (e: Event) => {
+      const path = e.composedPath();
+      if (!path) return;
+      if (path.indexOf(this.element) !== -1) {
+        let mayContinue = true;
+        try {
+          mayContinue = callback(e);
+        } catch (exception) {
+          console.warn(`Plugin error handing event: ${exception}`);
+        }
+        if (mayContinue === false) {
+          e.stopImmediatePropagation();
+          e.stopPropagation();
+          e.preventDefault();
+        }
+      }
+    };
+    container.addEventListener(event, handler, capture);
+    const unsubscribe = () =>
+      container.removeEventListener(event, handler, capture);
+    return unsubscribe;
+  }
+}
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
deleted file mode 100644
index a27c817..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
+++ /dev/null
@@ -1,138 +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>gr-event-helper</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-element id="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,
-    },
-  },
-});
-</script>
-
-</dom-element>
-
-<test-fixture id="basic">
-  <template>
-    <some-element></some-element>
-  </template>
-</test-fixture>
-
-<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';
-
-suite('gr-event-helper tests', () => {
-  let element;
-  let instance;
-  let sandbox;
-
-  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-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
new file mode 100644
index 0000000..25c0d43
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
@@ -0,0 +1,112 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+import {GrEventHelper} from './gr-event-helper.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+
+Polymer({
+  is: 'gr-event-helper-some-element',
+
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
+});
+
+const basicFixture = fixtureFromElement('gr-event-helper-some-element');
+
+suite('gr-event-helper tests', () => {
+  let element;
+  let instance;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    instance = new GrEventHelper(element);
+  });
+
+  test('onTap()', done => {
+    instance.onTap(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('onTap() cancel', () => {
+    const tapStub = sinon.stub();
+    addListener(element.parentElement, 'tap', tapStub);
+    instance.onTap(() => false);
+    MockInteractions.tap(element);
+    flush();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('onClick() cancel', () => {
+    const tapStub = sinon.stub();
+    element.parentElement.addEventListener('click', tapStub);
+    instance.onTap(() => false);
+    MockInteractions.tap(element);
+    flush();
+    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 = sinon.stub();
+    addListener(element.parentElement, 'tap', tapStub);
+    instance.captureTap(() => false);
+    MockInteractions.tap(element);
+    flush();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('captureClick() cancels click()', () => {
+    const tapStub = sinon.stub();
+    element.addEventListener('click', tapStub);
+    instance.captureTap(() => false);
+    MockInteractions.tap(element);
+    flush();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('on()', done => {
+    instance.on('foo', () => {
+      done();
+    });
+    element.dispatchEvent(
+        new CustomEvent('foo', {
+          composed: true, bubbles: true,
+        }));
+  });
+});
+
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
deleted file mode 100644
index 68b1494..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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,
-        value() { return []; },
-      },
-      _stylesApplied: {
-        type: Array,
-        value() { return []; },
-      },
-    };
-  }
-
-  _importHref(url, resolve, reject) {
-    // It is impossible to mock es6-module imported function.
-    // The _importHref function is mocked in test.
-    importHref(url, resolve, reject);
-  }
-
-  /**
-   * @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);
-    });
-  }
-
-  _applyStyle(name) {
-    if (this._stylesApplied.includes(name)) { return; }
-    this._stylesApplied.push(name);
-
-    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();
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
new file mode 100644
index 0000000..02786dd
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
@@ -0,0 +1,89 @@
+/**
+ * @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 '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-external-style_html';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {customElement, property} from '@polymer/decorators';
+
+@customElement('gr-external-style')
+class GrExternalStyle extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  // This is a required value for this component.
+  @property({type: String})
+  name!: string;
+
+  @property({type: Array})
+  _stylesApplied: string[] = [];
+
+  _applyStyle(name: string) {
+    if (this._stylesApplied.includes(name)) {
+      return;
+    }
+    this._stylesApplied.push(name);
+
+    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();
+  }
+
+  _importAndApply() {
+    getPluginEndpoints()
+      .getAndImportPlugins(this.name)
+      .then(() => {
+        const moduleNames = getPluginEndpoints().getModules(this.name);
+        for (const name of moduleNames) {
+          this._applyStyle(name);
+        }
+      });
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._importAndApply();
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => this._importAndApply());
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-external-style': 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
deleted file mode 100644
index c4310fc..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts
new file mode 100644
index 0000000..94196df
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 8f85348..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
+++ /dev/null
@@ -1,133 +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>gr-external-style</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-external-style name="foo"></gr-external-style>
-  </template>
-</test-fixture>
-
-<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';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-external-style integration tests', () => {
-  const TEST_URL = 'http://some/plugin/url.html';
-
-  let sandbox;
-  let element;
-  let plugin;
-  let importHrefStub;
-
-  const installPlugin = () => {
-    if (plugin) { return; }
-    pluginApi.install(p => {
-      plugin = p;
-    }, '0.1', TEST_URL);
-  };
-
-  const createElement = () => {
-    element = fixture('basic');
-    sandbox.spy(element, '_applyStyle');
-  };
-
-  /**
-   * 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);
-      },
-    });
-    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-external-style/gr-external-style_test.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
new file mode 100644
index 0000000..ad30f48
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.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 '../../../test/common-test-setup-karma.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import './gr-external-style.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+const basicFixture = fixtureFromTemplate(
+    html`<gr-external-style name="foo"></gr-external-style>`
+);
+
+suite('gr-external-style integration tests', () => {
+  const TEST_URL = 'http://some.com/plugins/url.html';
+
+  let element;
+  let plugin;
+
+  const installPlugin = () => {
+    if (plugin) { return; }
+    pluginApi.install(p => {
+      plugin = p;
+    }, '0.1', TEST_URL);
+  };
+
+  const createElement = () => {
+    element = basicFixture.instantiate();
+    sinon.spy(element, '_applyStyle');
+  };
+
+  /**
+   * 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(() => {
+    sinon.stub(getPluginEndpoints(), 'importUrl')
+        .callsFake( url => Promise.resolve());
+    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
+        .returns(Promise.resolve());
+  });
+
+  teardown(() => {
+    resetPlugins();
+    document.body.querySelectorAll('custom-style')
+        .forEach(style => style.remove());
+  });
+
+  test('imports plugin-provided module', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(getPluginEndpoints().importUrl.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);
+    // since loaded, should not call again
+    assert.isFalse(getPluginEndpoints().importUrl.calledOnce);
+  });
+
+  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._applyStyle.calledWith('some-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
deleted file mode 100644
index d1b2106..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ /dev/null
@@ -1,76 +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.
- */
-import '../../../scripts/bundled-polymer.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 {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-/** @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 &&
-          !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);
-
-    const pluginOpts = {};
-
-    if (shouldLoadTheme) {
-      // Theme needs to be loaded synchronous.
-      pluginOpts[config.default_theme] = {sync: true};
-    }
-
-    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);
-    });
-  }
-}
-
-customElements.define(GrPluginHost.is, GrPluginHost);
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
new file mode 100644
index 0000000..ed84406
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
@@ -0,0 +1,77 @@
+/**
+ * @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 '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {customElement, property} from '@polymer/decorators';
+import {ServerInfo} from '../../../types/common';
+
+@customElement('gr-plugin-host')
+class GrPluginHost extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  @property({type: Object, observer: '_configChanged'})
+  config?: ServerInfo;
+
+  _configChanged(config: ServerInfo) {
+    const plugins = config.plugin;
+    const htmlPlugins = (plugins && plugins.html_resource_paths) || [];
+    const jsPlugins = this._handleMigrations(
+      (plugins && plugins.js_resource_paths) || [],
+      htmlPlugins
+    );
+    const shouldLoadTheme =
+      !!config.default_theme &&
+      !getPluginLoader().isPluginPreloaded('preloaded:gerrit-theme');
+    // config.default_theme is defined when shouldLoadTheme is true
+    const themeToLoad: string[] = shouldLoadTheme
+      ? [config.default_theme!]
+      : [];
+
+    // Theme should be loaded first if has one to have better UX
+    const pluginsPending = themeToLoad.concat(jsPlugins, htmlPlugins);
+
+    const pluginOpts: {[key: string]: {sync: boolean}} = {};
+
+    if (shouldLoadTheme) {
+      // config.default_theme is defined when shouldLoadTheme is true
+      // Theme needs to be loaded synchronous.
+      pluginOpts[config.default_theme!] = {sync: true};
+    }
+
+    getPluginLoader().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: string[], htmlPlugins: string[]) {
+    return jsPlugins.filter(url => {
+      const counterpart = url.replace(/\.js$/, '.html');
+      return !htmlPlugins.includes(counterpart);
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-plugin-host': 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
deleted file mode 100644
index 2c8a8c0..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
+++ /dev/null
@@ -1,94 +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>gr-plugin-host</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-plugin-host></gr-plugin-host>
-  </template>
-</test-fixture>
-
-<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';
-
-suite('gr-plugin-host tests', () => {
-  let element;
-  let sandbox;
-
-  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-plugin-host/gr-plugin-host_test.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
new file mode 100644
index 0000000..7a99dc4
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-plugin-host.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+
+const basicFixture = fixtureFromElement('gr-plugin-host');
+
+suite('gr-plugin-host tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    sinon.stub(document.body, 'appendChild');
+  });
+
+  test('load plugins should be called', () => {
+    sinon.stub(getPluginLoader(), 'loadPlugins');
+    element.config = {
+      plugin: {
+        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+        js_resource_paths: ['plugins/42'],
+      },
+    };
+    assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
+    assert.isTrue(getPluginLoader().loadPlugins.calledWith([
+      'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+    ], {}));
+  });
+
+  test('theme plugins should be loaded if enabled', () => {
+    sinon.stub(getPluginLoader(), 'loadPlugins');
+    element.config = {
+      default_theme: 'gerrit-theme.html',
+      plugin: {
+        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+        js_resource_paths: ['plugins/42'],
+      },
+    };
+    assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
+    assert.isTrue(getPluginLoader().loadPlugins.calledWith([
+      'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+    ], {'gerrit-theme.html': {sync: true}}));
+  });
+
+  test('skip theme if preloaded', () => {
+    sinon.stub(getPluginLoader(), 'isPluginPreloaded')
+        .withArgs('preloaded:gerrit-theme')
+        .returns(true);
+    sinon.stub(getPluginLoader(), 'loadPlugins');
+    element.config = {
+      default_theme: '/oof',
+      plugin: {},
+    };
+    assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
+    assert.isTrue(getPluginLoader().loadPlugins.calledWith([], {}));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
new file mode 100644
index 0000000..410da215
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-types.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 {GrAttributeHelper} from './gr-attribute-helper/gr-attribute-helper';
+import {GrPluginRestApi} from '../shared/gr-js-api-interface/gr-plugin-rest-api';
+import {GrEventHelper} from './gr-event-helper/gr-event-helper';
+import {GrPopupInterface} from './gr-popup-interface/gr-popup-interface';
+import {ConfigInfo} from '../../types/common';
+
+interface GerritElementExtensions {
+  content?: HTMLElement & {hidden?: boolean};
+  change?: unknown;
+  revision?: unknown;
+  token?: string;
+  repoName?: string;
+  config?: ConfigInfo;
+}
+export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
+
+export interface HookApi {
+  onAttached(callback: HookCallback): HookApi;
+  onDetached(callback: HookCallback): HookApi;
+  getAllAttached(): HTMLElement[];
+  getLastAttached(): Promise<HTMLElement>;
+  getModuleName(): string;
+  handleInstanceDetached(instance: HTMLElement): void;
+  handleInstanceAttached(instance: HTMLElement): void;
+}
+
+export enum TargetElement {
+  CHANGE_ACTIONS = 'changeactions',
+  REPLY_DIALOG = 'replydialog',
+}
+
+// Note: for new events, naming convention should be: `a-b`
+export enum 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',
+}
+
+export interface RegisterOptions {
+  slot?: string;
+  replace: unknown;
+}
+
+export interface PanelInfo {
+  body: Element;
+  p: {[key: string]: any};
+  onUnload: () => void;
+}
+
+export interface SettingsInfo {
+  body: Element;
+  token?: string;
+  onUnload: () => void;
+  setTitle: () => void;
+  setWindowTitle: () => void;
+  show: () => void;
+}
+
+export interface PluginApi {
+  _url?: URL;
+  popup(): Promise<GrPopupInterface>;
+  popup(moduleName: string): Promise<GrPopupInterface>;
+  popup(moduleName?: string): Promise<GrPopupInterface | null>;
+  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
+  getPluginName(): string;
+  on(eventName: string, target: any): void;
+  attributeHelper(element: Element): GrAttributeHelper;
+  restApi(): GrPluginRestApi;
+  eventHelper(element: Node): GrEventHelper;
+  registerDynamicCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
+}
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
deleted file mode 100644
index db44cea..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.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.
- */
-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';
-
-  /** @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.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
new file mode 100644
index 0000000..7c6587a
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
@@ -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 '../../shared/gr-overlay/gr-overlay';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-plugin-popup_html';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {customElement} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-plugin-popup': GrPluginPopup;
+  }
+}
+
+export interface GrPluginPopup {
+  $: {
+    overlay: GrOverlay;
+  };
+}
+@customElement('gr-plugin-popup')
+export class GrPluginPopup extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  get opened() {
+    return this.$.overlay.opened;
+  }
+
+  open() {
+    return this.$.overlay.open();
+  }
+
+  close() {
+    this.$.overlay.close();
+  }
+}
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
deleted file mode 100644
index 5d2cae7..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts
new file mode 100644
index 0000000..aa7a92d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 2e65365..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
+++ /dev/null
@@ -1,69 +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>gr-plugin-popup</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-plugin-popup></gr-plugin-popup>
-  </template>
-</test-fixture>
-
-<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', 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-plugin-popup_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.js
new file mode 100644
index 0000000..f2d83e8
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.js
@@ -0,0 +1,50 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-plugin-popup.js';
+
+const basicFixture = fixtureFromElement('gr-plugin-popup');
+
+suite('gr-plugin-popup tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    stub('gr-overlay', {
+      open: sinon.stub().returns(Promise.resolve()),
+      close: sinon.stub(),
+    });
+  });
+
+  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);
+  });
+});
+
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
deleted file mode 100644
index 3363d72..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
+++ /dev/null
@@ -1,83 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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;
-};
-
-/**
- * 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.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
new file mode 100644
index 0000000..d45c263
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -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 './gr-plugin-popup';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GrPluginPopup} from './gr-plugin-popup';
+import {PluginApi} from '../gr-plugin-types';
+
+interface CustomPolymerPluginEl extends HTMLElement {
+  plugin: PluginApi;
+}
+
+/**
+ * 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.
+ */
+export class GrPopupInterface {
+  private _openingPromise: Promise<GrPopupInterface> | null = null;
+
+  private _popup: GrPluginPopup | null = null;
+
+  constructor(
+    readonly plugin: PluginApi,
+    private _moduleName: string | null = null
+  ) {}
+
+  _getElement() {
+    // TODO(TS): maybe consider removing this if no one is using
+    // anything other than native methods on the return
+    return (dom(this._popup) as unknown) as HTMLElement;
+  }
+
+  /**
+   * 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.
+   */
+  open(): Promise<GrPopupInterface> {
+    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 = popup.appendChild(
+              document.createElement(this._moduleName) as CustomPolymerPluginEl
+            );
+            el.plugin = this.plugin;
+          }
+          this._popup = hookEl.appendChild(popup);
+          flush();
+          return this._popup.open().then(() => this);
+        });
+    }
+    return this._openingPromise;
+  }
+
+  /**
+   * Hides the popup.
+   */
+  close() {
+    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
deleted file mode 100644
index 62ab0e7..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
+++ /dev/null
@@ -1,127 +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>gr-popup-interface</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="container">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<dom-module id="gr-user-test-popup">
-  <template>
-    <div id="barfoo">some test module</div>
-  </template>
-  <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 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(() => {
-      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();
-      });
-    });
-
-    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-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
new file mode 100644
index 0000000..be8836b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
@@ -0,0 +1,113 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {GrPopupInterface} from './gr-popup-interface.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils.js';
+
+class GrUserTestPopupElement extends PolymerElement {
+  static get is() { return 'gr-user-test-popup'; }
+
+  static get template() {
+    return html`<div id="barfoo">some test module</div>`;
+  }
+}
+
+customElements.define(GrUserTestPopupElement.is, GrUserTestPopupElement);
+
+const containerFixture = fixtureFromElement('div');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+suite('gr-popup-interface tests', () => {
+  let container;
+  let instance;
+  let plugin;
+  let ironOverlayBackdropStyleEl;
+
+  setup(() => {
+    ironOverlayBackdropStyleEl = createIronOverlayBackdropStyleEl();
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    container = containerFixture.instantiate();
+    sinon.stub(plugin, 'hook').returns({
+      getLastAttached() {
+        return Promise.resolve(container);
+      },
+    });
+  });
+
+  teardown(() => {
+    ironOverlayBackdropStyleEl.remove();
+  });
+
+  suite('manual', () => {
+    setup(() => {
+      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);
+        flush();
+        assert.equal(
+            container.querySelector('#foobar').textContent, 'manual content');
+        done();
+      });
+    });
+
+    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(
+            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();
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index f9a2bdf..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
+++ /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.
- */
-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-plugin-repo-command.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
new file mode 100644
index 0000000..b3a40c5
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
@@ -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.
+ */
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-plugin-repo-command_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-plugin-repo-command': GrPluginRepoCommand;
+  }
+}
+@customElement('gr-plugin-repo-command')
+export class GrPluginRepoCommand extends PolymerElement {
+  @property({type: String})
+  title = '';
+
+  static get template() {
+    return htmlTemplate;
+  }
+
+  _handleClick() {
+    this.dispatchEvent(
+      new CustomEvent('command-tap', {composed: true, bubbles: true})
+    );
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.ts
new file mode 100644
index 0000000..6eee643
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.ts
@@ -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';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+  </style>
+  <h3>[[title]]</h3>
+  <gr-button on-click="_handleClick">[[title]]</gr-button>
+`;
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
deleted file mode 100644
index 36d822c..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
+++ /dev/null
@@ -1,66 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import './gr-plugin-repo-command.js';
-const $_documentContainer = document.createElement('template');
-
-$_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;
-  }
-  this._createHook(title);
-  this._hook.onAttached(element => {
-    if (callback(element.repoName, element.config) === false) {
-      element.hidden = true;
-    }
-  });
-  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);
-  });
-  return this;
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
new file mode 100644
index 0000000..701a560
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
@@ -0,0 +1,69 @@
+/**
+ * @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 './gr-plugin-repo-command';
+import {ConfigInfo} from '../../../types/common';
+import {HookApi, PluginApi} from '../gr-plugin-types';
+
+type RepoCommandCallback = (repo?: string, config?: ConfigInfo) => boolean;
+
+/**
+ * Parameters provided on repo-command endpoint
+ */
+export interface GrRepoCommandEndpointEl extends HTMLElement {
+  repoName: string;
+  config: ConfigInfo;
+}
+
+export class GrRepoApi {
+  private _hook?: HookApi;
+
+  constructor(readonly plugin: PluginApi) {}
+
+  // TODO(TS): should mark as public since used in gr-change-metadata-api
+  _createHook(title: string) {
+    return this.plugin.hook('repo-command').onAttached(element => {
+      const pluginCommand = document.createElement('gr-plugin-repo-command');
+      pluginCommand.title = title;
+      element.appendChild(pluginCommand);
+    });
+  }
+
+  createCommand(title: string, callback: RepoCommandCallback) {
+    if (this._hook) {
+      console.warn('Already set up.');
+      return this._hook;
+    }
+    this._hook = this._createHook(title);
+    this._hook.onAttached(element => {
+      if (callback(element.repoName, element.config) === false) {
+        element.hidden = true;
+      }
+    });
+    return this;
+  }
+
+  onTap(callback: (event: Event) => boolean) {
+    if (!this._hook) {
+      console.warn('Call createCommand first.');
+      return this;
+    }
+    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
deleted file mode 100644
index 32ae959..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
+++ /dev/null
@@ -1,88 +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>gr-repo-api</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-endpoint-decorator name="repo-command">
-    </gr-endpoint-decorator>
-  </template>
-</test-fixture>
-
-<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-repo-api tests', () => {
-  let sandbox;
-  let 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();
-  });
-
-  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-repo-api/gr-repo-api_test.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js
new file mode 100644
index 0000000..9e24fda
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js
@@ -0,0 +1,73 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-endpoint-decorator name="repo-command">
+    </gr-endpoint-decorator>
+`);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-repo-api tests', () => {
+  let repoApi;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    getPluginLoader().loadPlugins([]);
+    repoApi = plugin.project();
+  });
+
+  teardown(() => {
+    repoApi = null;
+  });
+
+  test('exists', () => {
+    assert.isOk(repoApi);
+  });
+
+  test('works', done => {
+    const attachedStub = sinon.stub();
+    const tapStub = sinon.stub();
+    repoApi
+        .createCommand('foo', attachedStub)
+        .onTap(tapStub);
+    const element = basicFixture.instantiate();
+    flush(() => {
+      assert.isTrue(attachedStub.called);
+      const pluginCommand = element.shadowRoot
+          .querySelector('gr-plugin-repo-command');
+      assert.isOk(pluginCommand);
+      const btn = pluginCommand.shadowRoot
+          .querySelector('gr-button');
+      assert.isOk(btn);
+      assert.equal(btn.textContent, 'foo');
+      assert.isFalse(tapStub.called);
+      MockInteractions.tap(btn);
+      assert.isTrue(tapStub.called);
+      done();
+    });
+  });
+});
+
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
deleted file mode 100644
index 4fb971f..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
+++ /dev/null
@@ -1,73 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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);
-  });
-
-  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.ts b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
new file mode 100644
index 0000000..c7f1ecd
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
@@ -0,0 +1,68 @@
+/**
+ * @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.
+ */
+import '../../settings/gr-settings-view/gr-settings-item';
+import '../../settings/gr-settings-view/gr-settings-menu-item';
+import {PluginApi} from '../gr-plugin-types';
+
+export class GrSettingsApi {
+  private _token: string;
+
+  private _title = '(no title)';
+
+  private _moduleName?: string;
+
+  constructor(readonly plugin: PluginApi) {
+    // Generate default screen URL token, specific to plugin, and unique(ish).
+    this._token = plugin.getPluginName() + Math.random().toString(36).substr(5);
+  }
+
+  title(newTitle: string) {
+    this._title = newTitle;
+    return this;
+  }
+
+  token(newToken: string) {
+    this._token = newToken;
+    return this;
+  }
+
+  module(newModuleName: string) {
+    this._moduleName = newModuleName;
+    return this;
+  }
+
+  build() {
+    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.setAttribute('href', `#${token}`);
+      el.appendChild(menuItem);
+    });
+    const moduleName = this._moduleName;
+    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(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
deleted file mode 100644
index 5057992..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
+++ /dev/null
@@ -1,89 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-endpoint-decorator name="settings-menu-item">
-    </gr-endpoint-decorator>
-    <gr-endpoint-decorator name="settings-screen">
-    </gr-endpoint-decorator>
-  </template>
-</test-fixture>
-
-<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-settings-api tests', () => {
-  let sandbox;
-  let 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();
-  });
-
-  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-settings-api/gr-settings-api_test.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js
new file mode 100644
index 0000000..893f3e4
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-endpoint-decorator name="settings-menu-item">
+    </gr-endpoint-decorator>
+    <gr-endpoint-decorator name="settings-screen">
+    </gr-endpoint-decorator>
+`);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-settings-api tests', () => {
+  let settingsApi;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    getPluginLoader().loadPlugins([]);
+    settingsApi = plugin.settings();
+  });
+
+  teardown(() => {
+    settingsApi = null;
+  });
+
+  test('exists', () => {
+    assert.isOk(settingsApi);
+  });
+
+  test('works', done => {
+    settingsApi
+        .title('foo')
+        .token('bar')
+        .module('some-settings-screen')
+        .build();
+    const element = basicFixture.instantiate();
+    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();
+    });
+  });
+});
+
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
deleted file mode 100644
index 3da60db..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
+++ /dev/null
@@ -1,74 +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.
- */
-
-let styleObjectCount = 0;
-
-/** @constructor */
-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;
-  }
-  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));
-};
-
-export function GrStylesApi() {
-}
-
-/**
- * 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.ts b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
new file mode 100644
index 0000000..419c8db
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
@@ -0,0 +1,94 @@
+/**
+ * @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.
+ */
+
+/**
+ * @fileoverview We should consider dropping support for this API:
+ *
+ * 1. we need to try avoid using `innerHTML` for xss concerns
+ * 2. we have css variables which are more recommended way to custom styling
+ */
+
+/**
+ * // import { useShadow } from '@polymer/polymer/lib/utils/settings';
+ * TODO(TS): polymer/lib/utils/settings.d.ts is not exporting useShadow
+ * while the js is, to avoid the error, re-define it here
+ */
+const useShadow = !window.ShadyDOM || !window.ShadyDOM.inUse;
+
+let styleObjectCount = 0;
+
+interface PgElement extends Element {
+  __pg_js_api_style_tags: {
+    [className: string]: boolean;
+  };
+}
+
+export class GrStyleObject {
+  private className = '';
+
+  constructor(private readonly rulesStr: string) {
+    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.
+   *
+   */
+  getClassName(element: Element) {
+    let rootNodeEl = useShadow ? element.getRootNode() : document.body;
+    if (rootNodeEl === document) {
+      rootNodeEl = document.head;
+    }
+    // TODO(TS): type casting to have correct interface
+    // maybe move this __pg_xxx to attribute
+    const rootNode: PgElement = rootNodeEl as PgElement;
+    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.
+   *
+   */
+  apply(element: Element) {
+    element.classList.add(this.getClassName(element));
+  }
+}
+
+/**
+ * TODO(TS): move to util
+ */
+export class GrStylesApi {
+  /**
+   * Creates a new GrStyleObject with specified style properties.
+   */
+  css(ruleStr: string) {
+    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
deleted file mode 100644
index d6bae9b..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
+++ /dev/null
@@ -1,188 +0,0 @@
-<!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-admin-api</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="gr-style-test-element">
-  <template>
-    <div id="wrapper"></div>
-  </template>
-  <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 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';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-styles-api tests', () => {
-  let sandbox;
-  let 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();
-  });
-
-  teardown(() => {
-    stylesApi = null;
-    sandbox.restore();
-  });
-
-  test('exists', () => {
-    assert.isOk(stylesApi);
-  });
-
-  test('css', () => {
-    const styleObject = stylesApi.css('background: red');
-    assert.isDefined(styleObject);
-  });
-
-  suite('GrStyleObject tests', () => {
-    let sandbox;
-    let stylesApi;
-    let displayInlineStyle;
-    let displayNoneStyle;
-
-    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();
-      displayInlineStyle = stylesApi.css('display: inline');
-      displayNoneStyle = stylesApi.css('display: none');
-    });
-
-    teardown(() => {
-      displayInlineStyle = null;
-      displayNoneStyle = null;
-      stylesApi = null;
-      sandbox.restore();
-    });
-
-    function createNestedElements(parentElement) {
-      /* parentElement
-      *  |--- element1
-      *  |--- element2
-      *       |--- element3
-      **/
-      const element1 = document.createElement('div');
-      const element2 = document.createElement('div');
-      const element3 = document.createElement('div');
-      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);
-
-      testGetClassName(bodyLevelElements);
-    });
-
-    test('getClassName  - elements inside polymer element', () => {
-      const polymerElement = document.createElement('gr-style-test-element');
-      dom(document.body).appendChild(polymerElement);
-      const contentElements = createNestedElements(polymerElement.$.wrapper);
-
-      testGetClassName(contentElements);
-    });
-
-    function testGetClassName(elements) {
-      assertAllElementsHaveDefaultStyle(elements);
-
-      const className1 = displayInlineStyle.getClassName(elements[0]);
-      const className2 = displayNoneStyle.getClassName(elements[1]);
-      const className3 = displayInlineStyle.getClassName(elements[2]);
-
-      assert.notEqual(className2, className1);
-      assert.equal(className3, className1);
-
-      assertAllElementsHaveDefaultStyle(elements);
-
-      elements[0].classList.add(className1);
-      elements[1].classList.add(className2);
-      elements[2].classList.add(className1);
-
-      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
-    }
-
-    test('apply - body level elements', () => {
-      const bodyLevelElements = createNestedElements(document.body);
-
-      testApply(bodyLevelElements);
-    });
-
-    test('apply - elements inside polymer element', () => {
-      const polymerElement = document.createElement('gr-style-test-element');
-      dom(document.body).appendChild(polymerElement);
-      const contentElements = createNestedElements(polymerElement.$.wrapper);
-
-      testApply(contentElements);
-    });
-
-    function testApply(elements) {
-      assertAllElementsHaveDefaultStyle(elements);
-      displayInlineStyle.apply(elements[0]);
-      displayNoneStyle.apply(elements[1]);
-      displayInlineStyle.apply(elements[2]);
-      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
-    }
-
-    function assertAllElementsHaveDefaultStyle(elements) {
-      for (const element of elements) {
-        assert.equal(getComputedStyle(element).getPropertyValue('display'),
-            'block');
-      }
-    }
-
-    function assertDisplayPropertyValues(elements, expectedDisplayValues) {
-      for (const key in elements) {
-        if (elements.hasOwnProperty(key)) {
-          assert.equal(
-              getComputedStyle(elements[key]).getPropertyValue('display'),
-              expectedDisplayValues[key]);
-        }
-      }
-    }
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
new file mode 100644
index 0000000..c41b551
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
@@ -0,0 +1,185 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+class GrStyleTestElement extends PolymerElement {
+  static get is() { return 'gr-style-test-element'; }
+
+  static get template() {
+    return html`<div id="wrapper"></div>`;
+  }
+}
+
+customElements.define(GrStyleTestElement.is, GrStyleTestElement);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-styles-api tests', () => {
+  let stylesApi;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    getPluginLoader().loadPlugins([]);
+    stylesApi = plugin.styles();
+  });
+
+  teardown(() => {
+    stylesApi = null;
+  });
+
+  test('exists', () => {
+    assert.isOk(stylesApi);
+  });
+
+  test('css', () => {
+    const styleObject = stylesApi.css('background: red');
+    assert.isDefined(styleObject);
+  });
+
+  suite('GrStyleObject tests', () => {
+    let stylesApi;
+    let displayInlineStyle;
+    let displayNoneStyle;
+    let elementsToRemove;
+
+    setup(() => {
+      let plugin;
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      getPluginLoader().loadPlugins([]);
+      stylesApi = plugin.styles();
+      displayInlineStyle = stylesApi.css('display: inline');
+      displayNoneStyle = stylesApi.css('display: none');
+      elementsToRemove = [];
+    });
+
+    teardown(() => {
+      displayInlineStyle = null;
+      displayNoneStyle = null;
+      stylesApi = null;
+      elementsToRemove.forEach(element => {
+        element.remove();
+      });
+      elementsToRemove = null;
+      sinon.restore();
+    });
+
+    function createNestedElements(parentElement) {
+      /* parentElement
+      *  |--- element1
+      *  |--- element2
+      *       |--- element3
+      **/
+      const element1 = document.createElement('div');
+      const element2 = document.createElement('div');
+      const element3 = document.createElement('div');
+      parentElement.appendChild(element1);
+      parentElement.appendChild(element2);
+      element2.appendChild(element3);
+
+      if (parentElement === document.body) {
+        elementsToRemove.push(element1);
+        elementsToRemove.push(element2);
+      }
+
+      return [element1, element2, element3];
+    }
+
+    test('getClassName  - body level elements', () => {
+      const bodyLevelElements = createNestedElements(document.body);
+
+      testGetClassName(bodyLevelElements);
+    });
+
+    test('getClassName  - elements inside polymer element', () => {
+      const polymerElement = document.createElement('gr-style-test-element');
+      document.body.appendChild(polymerElement);
+      elementsToRemove.push(polymerElement);
+      const contentElements = createNestedElements(polymerElement.$.wrapper);
+
+      testGetClassName(contentElements);
+    });
+
+    function testGetClassName(elements) {
+      assertAllElementsHaveDefaultStyle(elements);
+
+      const className1 = displayInlineStyle.getClassName(elements[0]);
+      const className2 = displayNoneStyle.getClassName(elements[1]);
+      const className3 = displayInlineStyle.getClassName(elements[2]);
+
+      assert.notEqual(className2, className1);
+      assert.equal(className3, className1);
+
+      assertAllElementsHaveDefaultStyle(elements);
+
+      elements[0].classList.add(className1);
+      elements[1].classList.add(className2);
+      elements[2].classList.add(className1);
+
+      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
+    }
+
+    test('apply - body level elements', () => {
+      const bodyLevelElements = createNestedElements(document.body);
+
+      testApply(bodyLevelElements);
+    });
+
+    test('apply - elements inside polymer element', () => {
+      const polymerElement = document.createElement('gr-style-test-element');
+      document.body.appendChild(polymerElement);
+      elementsToRemove.push(polymerElement);
+      const contentElements = createNestedElements(polymerElement.$.wrapper);
+
+      testApply(contentElements);
+    });
+
+    function testApply(elements) {
+      assertAllElementsHaveDefaultStyle(elements);
+      displayInlineStyle.apply(elements[0]);
+      displayNoneStyle.apply(elements[1]);
+      displayInlineStyle.apply(elements[2]);
+      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
+    }
+
+    function assertAllElementsHaveDefaultStyle(elements) {
+      for (const element of elements) {
+        assert.equal(getComputedStyle(element).getPropertyValue('display'),
+            'block');
+      }
+    }
+
+    function assertDisplayPropertyValues(elements, expectedDisplayValues) {
+      for (const key in elements) {
+        if (elements.hasOwnProperty(key)) {
+          assert.equal(
+              getComputedStyle(elements[key]).getPropertyValue('display'),
+              expectedDisplayValues[key]);
+        }
+      }
+    }
+  });
+});
+
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
deleted file mode 100644
index 411a7c8..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
+++ /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.
- */
-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-custom-plugin-header.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
new file mode 100644
index 0000000..7d82ff4
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
@@ -0,0 +1,53 @@
+/**
+ * @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 {PolymerElement} from '@polymer/polymer/polymer-element';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-custom-plugin-header': GrCustomPluginHeader;
+  }
+}
+
+@customElement('gr-custom-plugin-header')
+export class GrCustomPluginHeader extends PolymerElement {
+  @property({type: String})
+  logoUrl = '';
+
+  @property({type: String})
+  title = '';
+
+  static get template() {
+    return 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>
+    `;
+  }
+}
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
deleted file mode 100644
index c987af3..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import './gr-custom-plugin-header.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-theme-api">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/** @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.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
new file mode 100644
index 0000000..821e4bf
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
@@ -0,0 +1,37 @@
+/**
+ * @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 './gr-custom-plugin-header';
+import {GrCustomPluginHeader} from './gr-custom-plugin-header';
+import {PluginApi} from '../gr-plugin-types';
+
+/**
+ * Defines api for theme, can be used to set header logo and title.
+ */
+export class GrThemeApi {
+  constructor(private readonly plugin: PluginApi) {}
+
+  setHeaderLogoAndTitle(logoUrl: string, title: string) {
+    this.plugin.hook('header-title', {replace: true}).onAttached(element => {
+      const customHeader: GrCustomPluginHeader = 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
deleted file mode 100644
index 9e2e190..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
+++ /dev/null
@@ -1,87 +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>gr-theme-api</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="header-title">
-  <template>
-    <gr-endpoint-decorator name="header-title">
-      <span class="titleText"></span>
-    </gr-endpoint-decorator>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js
new file mode 100644
index 0000000..787ab0b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js
@@ -0,0 +1,73 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const headerTitleFixture = fixtureFromTemplate(html`
+<gr-endpoint-decorator name="header-title">
+      <span class="titleText"></span>
+    </gr-endpoint-decorator>
+`);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-theme-api tests', () => {
+  let theme;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    theme = plugin.theme();
+  });
+
+  teardown(() => {
+    theme = null;
+  });
+
+  test('exists', () => {
+    assert.isOk(theme);
+  });
+
+  suite('header-title', () => {
+    let customHeader;
+
+    setup(() => {
+      headerTitleFixture.instantiate();
+      stub('gr-custom-plugin-header', {
+        /** @override */
+        ready() { customHeader = this; },
+      });
+      getPluginLoader().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();
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index b1b5ba7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ /dev/null
@@ -1,236 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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';
-
-/**
- * @extends Polymer.Element
- */
-class GrAccountInfo extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-account-info'; }
-  /**
-   * Fired when account details are changed.
-   *
-   * @event account-detail-update
-   */
-
-  static get properties() {
-    return {
-      usernameMutable: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeUsernameMutable(_serverConfig, _account.username)',
-      },
-      nameMutable: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeNameMutable(_serverConfig)',
-      },
-      hasUnsavedChanges: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
-          '_hasUsernameChange, _hasStatusChange, _hasDisplayNameChange)',
-      },
-
-      _hasNameChange: Boolean,
-      _hasUsernameChange: Boolean,
-      _hasDisplayNameChange: Boolean,
-      _hasStatusChange: Boolean,
-      _loading: {
-        type: Boolean,
-        value: false,
-      },
-      _saving: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      _account: Object,
-      _serverConfig: Object,
-      _username: {
-        type: String,
-        observer: '_usernameChanged',
-      },
-      _avatarChangeUrl: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_nameChanged(_account.name)',
-      '_statusChanged(_account.status)',
-      '_displayNameChanged(_account.display_name)',
-    ];
-  }
-
-  loadData() {
-    const promises = [];
-
-    this._loading = true;
-
-    promises.push(this.$.restAPI.getConfig().then(config => {
-      this._serverConfig = config;
-    }));
-
-    promises.push(this.$.restAPI.invalidateAccountsDetailCache());
-
-    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;
-    }));
-
-    return Promise.all(promises).then(() => {
-      this._loading = false;
-    });
-  }
-
-  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())
-        .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();
-  }
-
-  _maybeSetUsername() {
-    return this._hasUsernameChange && this.usernameMutable ?
-      this.$.restAPI.setAccountUsername(this._username) :
-      Promise.resolve();
-  }
-
-  _maybeSetDisplayName() {
-    return this._hasDisplayNameChange ?
-      this.$.restAPI.setAccountDisplayName(this._account.display_name) :
-      Promise.resolve();
-  }
-
-  _maybeSetStatus() {
-    return this._hasStatusChange ?
-      this.$.restAPI.setAccountStatus(this._account.status) :
-      Promise.resolve();
-  }
-
-  _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged,
-      displayNameChanged) {
-    return nameChanged || usernameChanged || statusChanged
-        || displayNameChanged;
-  }
-
-  _computeUsernameMutable(config, username) {
-    // Polymer 2: check for undefined
-    if ([
-      config,
-      username,
-    ].some(arg => arg === undefined)) {
-      return undefined;
-    }
-
-    // Username may not be changed once it is set.
-    return config.auth.editable_account_fields.includes('USER_NAME') &&
-        !username;
-  }
-
-  _computeNameMutable(config) {
-    return config.auth.editable_account_fields.includes('FULL_NAME');
-  }
-
-  _statusChanged() {
-    if (this._loading) { return; }
-    this._hasStatusChange = true;
-  }
-
-  _displayNameChanged() {
-    if (this._loading) { return; }
-    this._hasDisplayNameChange = true;
-  }
-
-  _usernameChanged() {
-    if (this._loading || !this._account) { return; }
-    this._hasUsernameChange =
-        (this._account.username || '') !== (this._username || '');
-  }
-
-  _nameChanged() {
-    if (this._loading) { return; }
-    this._hasNameChange = true;
-  }
-
-  _handleKeydown(e) {
-    if (e.keyCode === 13) { // Enter
-      e.stopPropagation();
-      this.save();
-    }
-  }
-
-  _hideAvatarChangeUrl(avatarChangeUrl) {
-    if (!avatarChangeUrl) {
-      return 'hide';
-    }
-
-    return '';
-  }
-}
-
-customElements.define(GrAccountInfo.is, GrAccountInfo);
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
new file mode 100644
index 0000000..8039bd8
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -0,0 +1,282 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../shared/gr-avatar/gr-avatar';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-account-info_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EditableAccountField} from '../../../constants/constants';
+
+export interface GrAccountInfo {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-account-info')
+export class GrAccountInfo extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when account details are changed.
+   *
+   * @event account-detail-update
+   */
+
+  @property({
+    type: Boolean,
+    notify: true,
+    computed: '_computeUsernameMutable(_serverConfig, _account.username)',
+  })
+  usernameMutable?: boolean;
+
+  @property({
+    type: Boolean,
+    notify: true,
+    computed: '_computeNameMutable(_serverConfig)',
+  })
+  nameMutable?: boolean;
+
+  @property({
+    type: Boolean,
+    notify: true,
+    computed:
+      '_computeHasUnsavedChanges(_hasNameChange, ' +
+      '_hasUsernameChange, _hasStatusChange, _hasDisplayNameChange)',
+  })
+  hasUnsavedChanges?: boolean;
+
+  @property({type: Boolean})
+  _hasNameChange?: boolean;
+
+  @property({type: Boolean})
+  _hasUsernameChange?: boolean;
+
+  @property({type: Boolean})
+  _hasDisplayNameChange?: boolean;
+
+  @property({type: Boolean})
+  _hasStatusChange?: boolean;
+
+  @property({type: Boolean})
+  _loading = false;
+
+  @property({type: Boolean})
+  _saving = false;
+
+  @property({type: Object})
+  _account?: AccountInfo;
+
+  @property({type: Object})
+  _serverConfig?: ServerInfo;
+
+  @property({type: String, observer: '_usernameChanged'})
+  _username?: string;
+
+  @property({type: String})
+  _avatarChangeUrl = '';
+
+  loadData() {
+    const promises = [];
+
+    this._loading = true;
+
+    promises.push(
+      this.$.restAPI.getConfig().then(config => {
+        this._serverConfig = config;
+      })
+    );
+
+    promises.push(this.$.restAPI.invalidateAccountsDetailCache());
+
+    promises.push(
+      this.$.restAPI.getAccount().then(account => {
+        if (!account) return;
+        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 || '';
+      })
+    );
+
+    return Promise.all(promises).then(() => {
+      this._loading = false;
+    });
+  }
+
+  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())
+      .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() {
+    // Note that we are intentionally not acting on this._account.name being the
+    // empty string (which is falsy).
+    return this._hasNameChange && this.nameMutable && this._account?.name
+      ? this.$.restAPI.setAccountName(this._account.name)
+      : Promise.resolve();
+  }
+
+  _maybeSetUsername() {
+    // Note that we are intentionally not acting on this._username being the
+    // empty string (which is falsy).
+    return this._hasUsernameChange && this.usernameMutable && this._username
+      ? this.$.restAPI.setAccountUsername(this._username)
+      : Promise.resolve();
+  }
+
+  _maybeSetDisplayName() {
+    return this._hasDisplayNameChange &&
+      this._account?.display_name !== undefined
+      ? this.$.restAPI.setAccountDisplayName(this._account.display_name)
+      : Promise.resolve();
+  }
+
+  _maybeSetStatus() {
+    return this._hasStatusChange && this._account?.status !== undefined
+      ? this.$.restAPI.setAccountStatus(this._account.status)
+      : Promise.resolve();
+  }
+
+  _computeHasUnsavedChanges(
+    nameChanged: boolean,
+    usernameChanged: boolean,
+    statusChanged: boolean,
+    displayNameChanged: boolean
+  ) {
+    return (
+      nameChanged || usernameChanged || statusChanged || displayNameChanged
+    );
+  }
+
+  _computeUsernameMutable(config: ServerInfo, username?: string) {
+    // Polymer 2: check for undefined
+    if ([config, username].includes(undefined)) {
+      return undefined;
+    }
+
+    // Username may not be changed once it is set.
+    return (
+      config.auth.editable_account_fields.includes(
+        EditableAccountField.USER_NAME
+      ) && !username
+    );
+  }
+
+  _computeNameMutable(config: ServerInfo) {
+    return config.auth.editable_account_fields.includes(
+      EditableAccountField.FULL_NAME
+    );
+  }
+
+  @observe('_account.status')
+  _statusChanged() {
+    if (this._loading) {
+      return;
+    }
+    this._hasStatusChange = true;
+  }
+
+  @observe('_account.display_name')
+  _displayNameChanged() {
+    if (this._loading) {
+      return;
+    }
+    this._hasDisplayNameChange = true;
+  }
+
+  _usernameChanged() {
+    if (this._loading || !this._account) {
+      return;
+    }
+    this._hasUsernameChange =
+      (this._account.username || '') !== (this._username || '');
+  }
+
+  @observe('_account.name')
+  _nameChanged() {
+    if (this._loading) {
+      return;
+    }
+    this._hasNameChange = true;
+  }
+
+  _handleKeydown(e: KeyboardEvent) {
+    if (e.keyCode === 13) {
+      // Enter
+      e.stopPropagation();
+      this.save();
+    }
+  }
+
+  _hideAvatarChangeUrl(avatarChangeUrl: string) {
+    if (!avatarChangeUrl) {
+      return 'hide';
+    }
+
+    return '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-info': 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
deleted file mode 100644
index fd94821..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
new file mode 100644
index 0000000..d69a279
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 53641d9..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ /dev/null
@@ -1,342 +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">
-<meta charset="utf-8">
-<title>gr-account-info</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-account-info></gr-account-info>
-  </template>
-</test-fixture>
-
-<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 = 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: []}};
-
-    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();
-  });
-
-  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);
-  });
-
-  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'));
-  });
-
-  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', '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);
-
-        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-account-info/gr-account-info_test.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
new file mode 100644
index 0000000..d359ad2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
@@ -0,0 +1,321 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-info.js';
+
+const basicFixture = fixtureFromElement('gr-account-info');
+
+suite('gr-account-info tests', () => {
+  let element;
+  let account;
+  let config;
+
+  function valueOf(title) {
+    const sections = 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 => {
+    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 = basicFixture.instantiate();
+    // Allow the element to render.
+    element.loadData().then(() => { flush(done); });
+  });
+
+  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);
+  });
+
+  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'));
+  });
+
+  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 = sinon.spy(element, '_nameChanged');
+      usernameChangedSpy = sinon.spy(element, '_usernameChanged');
+      statusChangedSpy = sinon.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
+
+      nameStub = sinon.stub(element.$.restAPI, 'setAccountName').callsFake(
+          name => Promise.resolve());
+      usernameStub = sinon.stub(element.$.restAPI, 'setAccountUsername')
+          .callsFake(username => Promise.resolve());
+      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
+          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 = sinon.spy(element, '_nameChanged');
+      statusChangedSpy = sinon.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME']}});
+
+      nameStub = sinon.stub(element.$.restAPI, 'setAccountName').callsFake(
+          name => Promise.resolve());
+      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
+          status => Promise.resolve());
+      sinon.stub(element.$.restAPI, 'setAccountUsername').callsFake(
+          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 = sinon.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: []}});
+
+      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
+          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();
+    flush();
+
+    assert.isFalse(element._hasUsernameChange);
+
+    element.set('_username', 'test');
+    flush();
+
+    assert.isTrue(element._hasUsernameChange);
+  });
+
+  test('_hideAvatarChangeUrl', () => {
+    assert.equal(element._hideAvatarChangeUrl(''), 'hide');
+
+    assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+  });
+});
+
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
deleted file mode 100644
index 390baf6..0000000
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
+++ /dev/null
@@ -1,68 +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.
- */
-
-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';
-
-/**
- * @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,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.loadData();
-  }
-
-  loadData() {
-    return this.$.restAPI.getAccountAgreements().then(agreements => {
-      this._agreements = agreements;
-    });
-  }
-
-  getUrl() {
-    return this.getBaseUrl() + '/settings/new-agreement';
-  }
-
-  getUrlBase(item) {
-    return this.getBaseUrl() + '/' + item;
-  }
-}
-
-customElements.define(GrAgreementsList.is, GrAgreementsList);
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
new file mode 100644
index 0000000..5523be1
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
@@ -0,0 +1,71 @@
+/**
+ * @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 '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-agreements-list_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ContributorAgreementInfo} from '../../../types/common';
+
+export interface GrAgreementsList {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-agreements-list')
+export class GrAgreementsList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array})
+  _agreements?: ContributorAgreementInfo[];
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.loadData();
+  }
+
+  loadData() {
+    return this.$.restAPI.getAccountAgreements().then(agreements => {
+      this._agreements = agreements;
+    });
+  }
+
+  getUrl() {
+    return `${getBaseUrl()}/settings/new-agreement`;
+  }
+
+  getUrlBase(item: string) {
+    return `${getBaseUrl()}/${item}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-agreements-list': 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
deleted file mode 100644
index 1cd9ce2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
new file mode 100644
index 0000000..194ca2b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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
deleted file mode 100644
index 3a2b86d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
+++ /dev/null
@@ -1,70 +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">
-<meta charset="utf-8">
-<title>gr-settings-view</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-agreements-list></gr-agreements-list>
-  </template>
-</test-fixture>
-
-<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',
-    }];
-
-    stub('gr-rest-api-interface', {
-      getAccountAgreements() { return Promise.resolve(agreements); },
-    });
-
-    element = fixture('basic');
-
-    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-agreements-list/gr-agreements-list_test.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
new file mode 100644
index 0000000..0c785aa
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_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 '../../../test/common-test-setup-karma.js';
+import './gr-agreements-list.js';
+
+const basicFixture = fixtureFromElement('gr-agreements-list');
+
+suite('gr-agreements-list tests', () => {
+  let element;
+  let agreements;
+
+  setup(done => {
+    agreements = [{
+      url: 'some url',
+      description: 'Agreements 1 description',
+      name: 'Agreements 1',
+    }];
+
+    stub('gr-rest-api-interface', {
+      getAccountAgreements() { return Promise.resolve(agreements); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = 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');
+  });
+});
+
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
deleted file mode 100644
index 61e8e93..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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';
-
-/**
- * @extends Polymer.Element
- */
-class GrChangeTableEditor extends mixinBehaviors( [
-  ChangeTableBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-table-editor'; }
-
-  static get properties() {
-    return {
-      displayedColumns: {
-        type: Array,
-        notify: true,
-      },
-      showNumber: {
-        type: Boolean,
-        notify: true,
-      },
-    };
-  }
-
-  /**
-   * Get the list of enabled column names from whichever checkboxes are
-   * 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);
-  }
-
-  /**
-   * 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 the number checkbox and update the showNumber property
-   * accordingly.
-   */
-  _handleNumberCheckboxClick(e) {
-    this.showNumber = 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());
-  }
-}
-
-customElements.define(GrChangeTableEditor.is, GrChangeTableEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
new file mode 100644
index 0000000..c0d6126
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -0,0 +1,92 @@
+/**
+ * @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 '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../../styles/gr-form-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-table-editor_html';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {customElement, property} from '@polymer/decorators';
+
+@customElement('gr-change-table-editor')
+class GrChangeTableEditor extends ChangeTableMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array, notify: true})
+  displayedColumns?: string[];
+
+  @property({type: Boolean, notify: true})
+  showNumber?: boolean;
+
+  /**
+   * Get the list of enabled column names from whichever checkboxes are
+   * checked (excluding the number checkbox).
+   */
+  _getDisplayedColumns() {
+    if (this.root === null) return [];
+    return (Array.from(
+      this.root.querySelectorAll('.checkboxContainer input:not([name=number])')
+    ) as HTMLInputElement[])
+      .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: MouseEvent) {
+    if (e.target === null) return;
+    const checkbox = (e.target as HTMLElement).querySelector('input');
+    if (!checkbox) {
+      return;
+    }
+    checkbox.click();
+  }
+
+  /**
+   * Handle a click on the number checkbox and update the showNumber property
+   * accordingly.
+   */
+  _handleNumberCheckboxClick(e: MouseEvent) {
+    this.showNumber = ((dom(e) as EventApi)
+      .rootTarget as HTMLInputElement).checked;
+  }
+
+  /**
+   * Handle a click on a displayed column checkboxes (excluding number) and
+   * update the displayedColumns property accordingly.
+   */
+  _handleTargetClick() {
+    this.set('displayedColumns', this._getDisplayedColumns());
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-table-editor': 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
deleted file mode 100644
index d63e627..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
new file mode 100644
index 0000000..1233cf1
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 79d1390..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
+++ /dev/null
@@ -1,171 +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">
-<meta charset="utf-8">
-<title>gr-settings-view</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-change-table-editor></gr-change-table-editor>
-  </template>
-</test-fixture>
-
-<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();
-
-    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.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-change-table-editor/gr-change-table-editor_test.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
new file mode 100644
index 0000000..42085ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-table-editor.js';
+
+const basicFixture = fixtureFromElement('gr-change-table-editor');
+
+suite('gr-change-table-editor tests', () => {
+  let element;
+  let columns;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    columns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+    ];
+
+    element.set('displayedColumns', columns);
+    element.showNumber = false;
+    flush();
+  });
+
+  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);
+    flush();
+
+    assert.equal(element.displayedColumns.length, displayedLength - 1);
+  });
+
+  test('show item', () => {
+    element.set('displayedColumns', [
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+    ]);
+    flush();
+    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);
+    flush();
+
+    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 relays taps to checkboxes', () => {
+    sinon.stub(element, '_handleNumberCheckboxClick');
+    sinon.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', () => {
+    sinon.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', () => {
+    sinon.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');
+  });
+});
+
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
deleted file mode 100644
index 957eb48..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
+++ /dev/null
@@ -1,176 +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.
- */
-
-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';
-
-/**
- * @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,
-      _agreementsText: String,
-      _agreementName: String,
-      _signedAgreements: Array,
-      _showAgreements: {
-        type: Boolean,
-        value: false,
-      },
-      _agreementsUrl: String,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.loadData();
-
-    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;
-    });
-  }
-
-  _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)) {
-        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';
-        }
-      }
-    }
-  }
-}
-
-customElements.define(GrClaView.is, GrClaView);
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
new file mode 100644
index 0000000..28cd672
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -0,0 +1,221 @@
+/**
+ * @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.
+ */
+
+import '@polymer/iron-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-cla-view_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ServerInfo,
+  GroupInfo,
+  ContributorAgreementInfo,
+} from '../../../types/common';
+
+export interface GrClaView {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-cla-view': GrClaView;
+  }
+}
+
+@customElement('gr-cla-view')
+export class GrClaView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  _groups?: GroupInfo[];
+
+  @property({type: Object})
+  _serverConfig?: ServerInfo;
+
+  @property({type: String})
+  _agreementsText?: string;
+
+  @property({type: String})
+  _agreementName?: string;
+
+  @property({type: Array})
+  _signedAgreements?: ContributorAgreementInfo[];
+
+  @property({type: Boolean})
+  _showAgreements = false;
+
+  @property({type: String})
+  _agreementsUrl?: string;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.loadData();
+
+    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 => {
+        if (!groups) return;
+        this._groups = groups.sort((a, b) =>
+          (a.name || '').localeCompare(b.name || '')
+        );
+      })
+    );
+
+    promises.push(
+      this.$.restAPI
+        .getAccountAgreements()
+        .then((agreements: ContributorAgreementInfo[] | undefined) => {
+          this._signedAgreements = agreements || [];
+        })
+    );
+
+    return Promise.all(promises);
+  }
+
+  _getAgreementsUrl(configUrl: string) {
+    let url;
+    if (!configUrl) {
+      return '';
+    }
+    if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
+      url = configUrl;
+    } else {
+      url = getBaseUrl() + '/' + configUrl;
+    }
+
+    return url;
+  }
+
+  _handleShowAgreement(e: Event) {
+    this._agreementName = (e.target as HTMLInputElement).getAttribute(
+      'data-name'
+    )!;
+    const url = (e.target as HTMLInputElement).getAttribute('data-url')!;
+    this._agreementsUrl = this._getAgreementsUrl(url);
+    this._showAgreements = true;
+  }
+
+  _handleSaveAgreements() {
+    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 submitted.';
+      }
+      this._createToast(message);
+      this.loadData();
+      this._agreementsText = '';
+      this._showAgreements = false;
+    });
+  }
+
+  _createToast(message: string) {
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {message},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+
+  _computeShowAgreementsClass(showAgreements: boolean) {
+    return showAgreements ? 'show' : '';
+  }
+
+  _disableAgreements(
+    item: ContributorAgreementInfo,
+    groups: GroupInfo[],
+    signedAgreements: ContributorAgreementInfo[]
+  ) {
+    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: ContributorAgreementInfo,
+    groups: GroupInfo[],
+    signedAgreements: ContributorAgreementInfo[]
+  ) {
+    return this._disableAgreements(item, groups, signedAgreements)
+      ? ''
+      : 'hide';
+  }
+
+  _disableAgreementsText(text: string) {
+    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: string,
+    contributorAgreements: ContributorAgreementInfo[]
+  ) {
+    if (!contributorAgreements) return '';
+    return contributorAgreements.some(
+      (contributorAgreement: ContributorAgreementInfo) =>
+        name === contributorAgreement.name &&
+        !contributorAgreement.auto_verify_group
+    )
+      ? 'hideAgreementsTextBox'
+      : '';
+  }
+}
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
deleted file mode 100644
index 2d371e2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
new file mode 100644
index 0000000..c461718
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -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';
+
+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 class="heading-1">New Contributor Agreement</h1>
+    <h3 class="heading-3">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="heading-3">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="heading-3">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
deleted file mode 100644
index bc3c10c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
+++ /dev/null
@@ -1,194 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-cla-view></gr-cla-view>
-  </template>
-</test-fixture>
-
-<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,
-      },
-      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',
-    },
-  };
-
-  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},
-    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); });
-  });
-
-  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-cla-view/gr-cla-view_test.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
new file mode 100644
index 0000000..aeacea4
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
@@ -0,0 +1,180 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-cla-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-cla-view');
+
+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',
+    },
+  };
+
+  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},
+    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 = basicFixture.instantiate();
+    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.isNotOk(
+        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');
+  });
+});
+
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
deleted file mode 100644
index 2a7ac06..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
+++ /dev/null
@@ -1,99 +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.
- */
-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-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';
-
-/** @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,
-        value: false,
-      },
-
-      /** @type {?} */
-      editPrefs: Object,
-    };
-  }
-
-  loadData() {
-    return this.$.restAPI.getEditPreferences().then(prefs => {
-      this.editPrefs = prefs;
-    });
-  }
-
-  _handleEditPrefsChanged() {
-    this.hasUnsavedChanges = true;
-  }
-
-  _handleEditSyntaxHighlightingChanged() {
-    this.set('editPrefs.syntax_highlighting',
-        this.$.editSyntaxHighlighting.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleEditShowTabsChanged() {
-    this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleMatchBracketsChanged() {
-    this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleEditLineWrappingChanged() {
-    this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleIndentWithTabsChanged() {
-    this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleAutoCloseBracketsChanged() {
-    this.set('editPrefs.auto_close_brackets',
-        this.$.showAutoCloseBrackets.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  save() {
-    return this.$.restAPI.saveEditPreferences(this.editPrefs).then(res => {
-      this.hasUnsavedChanges = false;
-    });
-  }
-}
-
-customElements.define(GrEditPreferences.is, GrEditPreferences);
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
new file mode 100644
index 0000000..72283d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -0,0 +1,123 @@
+/**
+ * @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.
+ */
+import '@polymer/iron-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-edit-preferences_html';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EditPreferencesInfo} from '../../../types/common';
+
+export interface GrEditPreferences {
+  $: {
+    restAPI: RestApiService & Element;
+    editSyntaxHighlighting: HTMLInputElement;
+    showAutoCloseBrackets: HTMLInputElement;
+    showIndentWithTabs: HTMLInputElement;
+    showMatchBrackets: HTMLInputElement;
+    editShowLineWrapping: HTMLInputElement;
+    editShowTabs: HTMLInputElement;
+    editShowTrailingWhitespaceInput: HTMLInputElement;
+  };
+}
+@customElement('gr-edit-preferences')
+export class GrEditPreferences extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, notify: true})
+  hasUnsavedChanges = false;
+
+  @property({type: Object})
+  editPrefs?: EditPreferencesInfo;
+
+  loadData() {
+    return this.$.restAPI.getEditPreferences().then(prefs => {
+      this.editPrefs = prefs;
+    });
+  }
+
+  _handleEditPrefsChanged() {
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleEditSyntaxHighlightingChanged() {
+    this.set(
+      'editPrefs.syntax_highlighting',
+      this.$.editSyntaxHighlighting.checked
+    );
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditShowTabsChanged() {
+    this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditShowTrailingWhitespaceTap() {
+    this.set(
+      'editPrefs.show_whitespace_errors',
+      this.$.editShowTrailingWhitespaceInput.checked
+    );
+    this._handleEditPrefsChanged();
+  }
+
+  _handleMatchBracketsChanged() {
+    this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditLineWrappingChanged() {
+    this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  _handleIndentWithTabsChanged() {
+    this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
+    this._handleEditPrefsChanged();
+  }
+
+  _handleAutoCloseBracketsChanged() {
+    this.set(
+      'editPrefs.auto_close_brackets',
+      this.$.showAutoCloseBrackets.checked
+    );
+    this._handleEditPrefsChanged();
+  }
+
+  save() {
+    if (!this.editPrefs)
+      return Promise.reject(new Error('Missing edit preferences'));
+    return this.$.restAPI.saveEditPreferences(this.editPrefs).then(() => {
+      this.hasUnsavedChanges = false;
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-edit-preferences': 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
deleted file mode 100644
index f2c476a..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js
+++ /dev/null
@@ -1,164 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
new file mode 100644
index 0000000..93a2d1c
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
@@ -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';
+
+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">Show trailing whitespace</span>
+      <span class="value">
+        <input
+          id="editShowTrailingWhitespaceInput"
+          type="checkbox"
+          checked$="[[editPrefs.show_whitespace_errors]]"
+          on-change="_handleEditShowTrailingWhitespaceTap"
+        />
+      </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
deleted file mode 100644
index 3cc7bfe..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
+++ /dev/null
@@ -1,126 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-edit-preferences></gr-edit-preferences>
-  </template>
-</test-fixture>
-
-<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');
-      }
-    }
-  }
-
-  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();
-  });
-
-  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);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
new file mode 100644
index 0000000..b1b8b61
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
@@ -0,0 +1,110 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-edit-preferences.js';
+
+const basicFixture = fixtureFromElement('gr-edit-preferences');
+
+suite('gr-edit-preferences tests', () => {
+  let element;
+
+  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');
+      }
+    }
+  }
+
+  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 = basicFixture.instantiate();
+
+    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);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', () => {
+    sinon.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);
+    });
+  });
+});
+
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
deleted file mode 100644
index fc97079..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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,
-        value: false,
-      },
-
-      _emails: Array,
-      _emailsToRemove: {
-        type: Array,
-        value() { return []; },
-      },
-      /** @type {?string} */
-      _newPreferred: {
-        type: String,
-        value: null,
-      },
-    };
-  }
-
-  loadData() {
-    return this.$.restAPI.getAccountEmails().then(emails => {
-      this._emails = emails;
-    });
-  }
-
-  save() {
-    const promises = [];
-
-    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);
-      }
-    }
-  }
-}
-
-customElements.define(GrEmailEditor.is, GrEmailEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
new file mode 100644
index 0000000..fd10a16
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -0,0 +1,123 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-email-editor_html';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EmailInfo} from '../../../types/common';
+
+export interface GrEmailEditor {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-email-editor')
+export class GrEmailEditor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, notify: true})
+  hasUnsavedChanges = false;
+
+  @property({type: Array})
+  _emails: EmailInfo[] = [];
+
+  @property({type: Array})
+  _emailsToRemove: EmailInfo[] = [];
+
+  @property({type: String})
+  _newPreferred: string | null = null;
+
+  loadData() {
+    return this.$.restAPI.getAccountEmails().then(emails => {
+      this._emails = emails ?? [];
+    });
+  }
+
+  save() {
+    const promises: Promise<unknown>[] = [];
+
+    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: Event) {
+    const target = (dom(e) as EventApi).localTarget;
+    if (!(target instanceof Element)) return;
+    const indexStr = target.getAttribute('data-index');
+    if (indexStr === null) return;
+    const index = Number(indexStr);
+    const email = this._emails[index];
+    this.push('_emailsToRemove', email);
+    this.splice('_emails', index, 1);
+    this.hasUnsavedChanges = true;
+  }
+
+  _handlePreferredControlClick(e: Event) {
+    if (
+      e.target instanceof HTMLElement &&
+      e.target.classList.contains('preferredControl') &&
+      e.target.firstElementChild instanceof HTMLInputElement
+    ) {
+      e.target.firstElementChild.click();
+    }
+  }
+
+  _handlePreferredChange(e: Event) {
+    if (!(e.target instanceof HTMLInputElement)) return;
+    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);
+      }
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-email-editor': 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
deleted file mode 100644
index 977e95d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
new file mode 100644
index 0000000..525fca6
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
@@ -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';
+
+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
deleted file mode 100644
index ad2553d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
+++ /dev/null
@@ -1,152 +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">
-<meta charset="utf-8">
-<title>gr-email-editor</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-email-editor></gr-email-editor>
-  </template>
-</test-fixture>
-
-<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'},
-    ];
-
-    stub('gr-rest-api-interface', {
-      getAccountEmails() { return Promise.resolve(emails); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(flush(done));
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 3);
-
-    assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
-    assert.isNotOk(rows[0].querySelector('gr-button').disabled);
-
-    assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
-    assert.isOk(rows[1].querySelector('gr-button').disabled);
-
-    assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
-    assert.isNotOk(rows[2].querySelector('gr-button').disabled);
-
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('edit preferred', () => {
-    const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
-    const radios = element.shadowRoot
-        .querySelector('table').querySelectorAll('input[type=radio]');
-
-    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);
-
-    radios[0].click();
-
-    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);
-  });
-
-  test('delete email', () => {
-    const buttons = element.shadowRoot
-        .querySelector('table').querySelectorAll('gr-button');
-
-    assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
-
-    buttons[2].click();
-
-    assert.isTrue(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 1);
-    assert.equal(element._emails.length, 2);
-
-    assert.equal(element._emailsToRemove[0].email, 'email@three.com');
-  });
-
-  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.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
-
-    // 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(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);
-
-    // 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-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
new file mode 100644
index 0000000..18ff95c
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -0,0 +1,153 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-email-editor.js';
+import {GrEmailEditor} from './gr-email-editor';
+
+const basicFixture = fixtureFromElement('gr-email-editor');
+
+suite('gr-email-editor tests', () => {
+  let element: GrEmailEditor;
+
+  setup(async () => {
+    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 = basicFixture.instantiate();
+
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', () => {
+    const rows = element
+      .shadowRoot!.querySelector('table')!
+      .querySelectorAll('tbody tr') as NodeListOf<HTMLTableRowElement>;
+
+    assert.equal(rows.length, 3);
+
+    assert.isFalse(
+      (rows[0].querySelector('input[type=radio]') as HTMLInputElement).checked
+    );
+    assert.isNotOk(rows[0].querySelector('gr-button')!.disabled);
+
+    assert.isTrue(
+      (rows[1].querySelector('input[type=radio]') as HTMLInputElement).checked
+    );
+    assert.isOk(rows[1].querySelector('gr-button')!.disabled);
+
+    assert.isFalse(
+      (rows[2].querySelector('input[type=radio]') as HTMLInputElement).checked
+    );
+    assert.isNotOk(rows[2].querySelector('gr-button')!.disabled);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('edit preferred', () => {
+    const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
+    const radios = element
+      .shadowRoot!.querySelector('table')!
+      .querySelectorAll('input[type=radio]') as NodeListOf<HTMLInputElement>;
+
+    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);
+
+    radios[0].click();
+
+    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);
+  });
+
+  test('delete email', () => {
+    const buttons = element
+      .shadowRoot!.querySelector('table')!
+      .querySelectorAll('gr-button');
+
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+
+    buttons[2].click();
+
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 1);
+    assert.equal(element._emails.length, 2);
+
+    assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+  });
+
+  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.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+
+    // Delete the first email and set the last as preferred.
+    rows[0].querySelector('gr-button')!.click();
+    (rows[2].querySelector('input[type=radio]')! as HTMLInputElement).click();
+
+    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);
+
+    // 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();
+    });
+  });
+});
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
deleted file mode 100644
index 90631c7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
+++ /dev/null
@@ -1,126 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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,
-        notify: true,
-      },
-      _keys: Array,
-      /** @type {?} */
-      _keyToView: Object,
-      _newKey: {
-        type: String,
-        value: '',
-      },
-      _keysToRemove: {
-        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 = 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.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
new file mode 100644
index 0000000..21e414b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -0,0 +1,136 @@
+/**
+ * @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-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-form-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-gpg-editor_html';
+import {customElement, property} from '@polymer/decorators';
+import {GpgKeyInfo, GpgKeyId} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrGpgEditor {
+  $: {
+    restAPI: RestApiService & Element;
+    viewKeyOverlay: GrOverlay;
+    addButton: GrButton;
+    newKey: IronAutogrowTextareaElement;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-gpg-editor': GrGpgEditor;
+  }
+}
+@customElement('gr-gpg-editor')
+export class GrGpgEditor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, notify: true})
+  hasUnsavedChanges = false;
+
+  @property({type: Array})
+  _keys: GpgKeyInfo[] = [];
+
+  @property({type: Object})
+  _keyToView?: GpgKeyInfo;
+
+  @property({type: String})
+  _newKey = '';
+
+  @property({type: Array})
+  _keysToRemove: GpgKeyInfo[] = [];
+
+  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 as GpgKeyId;
+        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: Event) {
+    const el = (dom(e) as EventApi).localTarget as Element;
+    const index = Number(el.getAttribute('data-index')!);
+    this._keyToView = this._keys[index];
+    this.$.viewKeyOverlay.open();
+  }
+
+  _closeOverlay() {
+    this.$.viewKeyOverlay.close();
+  }
+
+  _handleDeleteKey(e: Event) {
+    const el = (dom(e) as EventApi).localTarget as Element;
+    const index = Number(el.getAttribute('data-index')!);
+    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(() => {
+        this.$.newKey.disabled = false;
+        this._newKey = '';
+        this.loadData();
+      })
+      .catch(() => {
+        this.$.addButton.disabled = false;
+        this.$.newKey.disabled = false;
+      });
+  }
+
+  _computeAddButtonDisabled(newKey: string) {
+    return !newKey.length;
+  }
+}
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
deleted file mode 100644
index 19b8d0c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
new file mode 100644
index 0000000..432bc4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 4a0af5b..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
+++ /dev/null
@@ -1,195 +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">
-<meta charset="utf-8">
-<title>gr-gpg-editor</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-gpg-editor></gr-gpg-editor>
-  </template>
-</test-fixture>
-
-<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: [],
-      },
-    };
-
-    stub('gr-rest-api-interface', {
-      getAccountGPGKeys() { return Promise.resolve(keys); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders', () => {
-    const rows = dom(element.root).querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 2);
-
-    let cells = rows[0].querySelectorAll('td');
-    assert.equal(cells[0].textContent, 'AFC8A49B');
-
-    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);
-      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-gpg-editor/gr-gpg-editor_test.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
new file mode 100644
index 0000000..2792176
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
@@ -0,0 +1,180 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-gpg-editor.js';
+
+const basicFixture = fixtureFromElement('gr-gpg-editor');
+
+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: [],
+      },
+    };
+
+    stub('gr-rest-api-interface', {
+      getAccountGPGKeys() { return Promise.resolve(keys); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = element.root.querySelectorAll('tbody tr');
+
+    assert.equal(rows.length, 2);
+
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AFC8A49B');
+
+    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')
+        .callsFake(() => Promise.resolve());
+
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = 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 = 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').callsFake(
+        () => 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').callsFake(
+        () => 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]});
+  });
+});
+
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
deleted file mode 100644
index 1cc1369..0000000
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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) => a.name.localeCompare(b.name));
-    });
-  }
-
-  _computeVisibleToAll(group) {
-    return group.options.visible_to_all ? 'Yes' : 'No';
-  }
-
-  _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 GerritNav.getUrlForGroup(decodeURIComponent(group.id));
-  }
-}
-
-customElements.define(GrGroupList.is, GrGroupList);
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
new file mode 100644
index 0000000..d631c53
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
@@ -0,0 +1,73 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../../styles/gr-form-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-group-list_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {customElement, property} from '@polymer/decorators';
+import {GroupInfo, GroupId} from '../../../types/common';
+
+export interface GrGroupList {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group-list': GrGroupList;
+  }
+}
+@customElement('gr-group-list')
+export class GrGroupList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array})
+  _groups: GroupInfo[] = [];
+
+  loadData() {
+    return this.$.restAPI.getAccountGroups().then(groups => {
+      if (!groups) return;
+      this._groups = groups.sort((a, b) =>
+        (a.name || '').localeCompare(b.name || '')
+      );
+    });
+  }
+
+  _computeVisibleToAll(group: GroupInfo) {
+    return group.options && group.options.visible_to_all ? 'Yes' : 'No';
+  }
+
+  _computeGroupPath(group: GroupInfo) {
+    if (!group || !group.id) {
+      return;
+    }
+
+    // Group ID is already encoded from the API
+    // Decode it here to match with our router encoding behavior
+    return GerritNav.getUrlForGroup(decodeURIComponent(group.id) as GroupId);
+  }
+}
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
deleted file mode 100644
index d5350aa..0000000
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
new file mode 100644
index 0000000..e52583d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 2fdc7b3..0000000
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ /dev/null
@@ -1,124 +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">
-<meta charset="utf-8">
-<title>gr-settings-view</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-group-list></gr-group-list>
-  </template>
-</test-fixture>
-
-<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';
-
-suite('gr-group-list tests', () => {
-  let sandbox;
-  let element;
-  let 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',
-    }];
-
-    stub('gr-rest-api-interface', {
-      getAccountGroups() { return Promise.resolve(groups); },
-    });
-
-    element = fixture('basic');
-
-    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-group-list/gr-group-list_test.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
new file mode 100644
index 0000000..e19345a
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-group-list.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-group-list');
+
+suite('gr-group-list tests', () => {
+  let element;
+  let groups;
+
+  setup(done => {
+    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',
+    }];
+
+    stub('gr-rest-api-interface', {
+      getAccountGroups() { return Promise.resolve(groups); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = Array.from(
+        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 = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
+        () => '/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 = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
+        () => '/admin/groups/user/test');
+
+    group = {
+      id: 'user%2Ftest',
+    };
+    assert.equal(element._computeGroupPath(group),
+        '/admin/groups/user/test');
+  });
+});
+
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
deleted file mode 100644
index 02657f8..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.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 {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';
-
-/** @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,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.loadData();
-  }
-
-  loadData() {
-    const promises = [];
-
-    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;
-    }));
-
-    return Promise.all(promises);
-  }
-
-  _handleGenerateTap() {
-    this._generatedPassword = 'Generating...';
-    this.$.generatedPasswordOverlay.open();
-    this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
-      this._generatedPassword = newPassword;
-    });
-  }
-
-  _closeOverlay() {
-    this.$.generatedPasswordOverlay.close();
-  }
-
-  _generatedPasswordOverlayClosed() {
-    this._generatedPassword = '';
-  }
-}
-
-customElements.define(GrHttpPassword.is, GrHttpPassword);
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
new file mode 100644
index 0000000..02683e3
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -0,0 +1,106 @@
+/**
+ * @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 '../../../styles/gr-form-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-http-password_html';
+import {property, customElement} from '@polymer/decorators';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-http-password': GrHttpPassword;
+  }
+}
+
+export interface GrHttpPassword {
+  $: {
+    restAPI: RestApiService & Element;
+    generatedPasswordOverlay: GrOverlay;
+  };
+}
+
+@customElement('gr-http-password')
+export class GrHttpPassword extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  _username?: string;
+
+  @property({type: String})
+  _generatedPassword?: string;
+
+  @property({type: String})
+  _passwordUrl: string | null = null;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.loadData();
+  }
+
+  loadData() {
+    const promises = [];
+
+    promises.push(
+      this.$.restAPI.getAccount().then(account => {
+        if (account) {
+          this._username = account.username;
+        }
+      })
+    );
+
+    promises.push(
+      this.$.restAPI.getConfig().then(info => {
+        if (info) {
+          this._passwordUrl = info.auth.http_password_url || null;
+        } else {
+          this._passwordUrl = null;
+        }
+      })
+    );
+
+    return Promise.all(promises);
+  }
+
+  _handleGenerateTap() {
+    this._generatedPassword = 'Generating...';
+    this.$.generatedPasswordOverlay.open();
+    this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
+      this._generatedPassword = newPassword;
+    });
+  }
+
+  _closeOverlay() {
+    this.$.generatedPasswordOverlay.close();
+  }
+
+  _generatedPasswordOverlayClosed() {
+    this._generatedPassword = '';
+  }
+}
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
deleted file mode 100644
index 0474b99..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
new file mode 100644
index 0000000..41084b5
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 26fa84d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
+++ /dev/null
@@ -1,91 +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">
-<meta charset="utf-8">
-<title>gr-settings-view</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-http-password></gr-http-password>
-  </template>
-</test-fixture>
-
-<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: {}};
-
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(account); },
-      getConfig() { return Promise.resolve(config); },
-    });
-
-    element = fixture('basic');
-    element.loadData().then(() => { flush(done); });
-  });
-
-  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;
-        }));
-
-    assert.isNotOk(element._generatedPassword);
-
-    MockInteractions.tap(button);
-
-    assert.isTrue(generateStub.called);
-    assert.equal(element._generatedPassword, 'Generating...');
-
-    generateResolve(nextPassword);
-
-    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-http-password/gr-http-password_test.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
new file mode 100644
index 0000000..920ad48
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-http-password.js';
+
+const basicFixture = fixtureFromElement('gr-http-password');
+
+suite('gr-http-password tests', () => {
+  let element;
+  let account;
+  let config;
+
+  setup(done => {
+    account = {username: 'user name'};
+    config = {auth: {}};
+
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(account); },
+      getConfig() { return Promise.resolve(config); },
+    });
+
+    element = basicFixture.instantiate();
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('generate password', () => {
+    const button = element.$.generateButton;
+    const nextPassword = 'the new password';
+    let generateResolve;
+    const generateStub = sinon.stub(element.$.restAPI,
+        'generateAccountHttpPassword')
+        .callsFake(() => new Promise(resolve => {
+          generateResolve = resolve;
+        }));
+
+    assert.isNotOk(element._generatedPassword);
+
+    MockInteractions.tap(button);
+
+    assert.isTrue(generateStub.called);
+    assert.equal(element._generatedPassword, 'Generating...');
+
+    generateResolve(nextPassword);
+
+    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();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
deleted file mode 100644
index 74c5eed..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
+++ /dev/null
@@ -1,120 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const AUTH = [
-  'OPENID',
-  'OAUTH',
-];
-
-/**
- * @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,
-      _showLinkAnotherIdentity: {
-        type: Boolean,
-        computed: '_computeShowLinkAnotherIdentity(serverConfig)',
-      },
-    };
-  }
-
-  loadData() {
-    return this.$.restAPI.getExternalIds().then(id => {
-      this._identities = id;
-    });
-  }
-
-  _computeIdentity(id) {
-    return id && id.startsWith('mailto:') ? '' : id;
-  }
-
-  _computeHideDeleteClass(canDelete) {
-    return canDelete ? 'show' : '';
-  }
-
-  _handleDeleteItemConfirm() {
-    this.$.overlay.close();
-    return this.$.restAPI.deleteAccountIdentity([this._idName])
-        .then(() => { this.loadData(); });
-  }
-
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
-  }
-
-  _handleDeleteItem(e) {
-    const name = e.model.get('item.identity');
-    if (!name) { return; }
-    this._idName = name;
-    this.$.overlay.open();
-  }
-
-  _computeIsTrusted(item) {
-    return item ? '' : 'Untrusted';
-  }
-
-  filterIdentities(item) {
-    return !item.identity.startsWith('username:');
-  }
-
-  _computeShowLinkAnotherIdentity(config) {
-    if (config && config.auth &&
-        config.auth.git_basic_auth_policy) {
-      return AUTH.includes(
-          config.auth.git_basic_auth_policy.toUpperCase());
-    }
-
-    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';
-  }
-}
-
-customElements.define(GrIdentities.is, GrIdentities);
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
new file mode 100644
index 0000000..5f2b6a5
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -0,0 +1,130 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../../styles/gr-form-styles';
+import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-identities_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountExternalIdInfo, ServerInfo} from '../../../types/common';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+
+const AUTH = ['OPENID', 'OAUTH'];
+
+export interface GrIdentities {
+  $: {
+    restAPI: RestApiService & Element;
+    overlay: GrOverlay;
+  };
+}
+
+@customElement('gr-identities')
+export class GrIdentities extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array})
+  _identities: AccountExternalIdInfo[] = [];
+
+  @property({type: String})
+  _idName?: string;
+
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({
+    type: Boolean,
+    computed: '_computeShowLinkAnotherIdentity(serverConfig)',
+  })
+  _showLinkAnotherIdentity?: boolean;
+
+  loadData() {
+    return this.$.restAPI.getExternalIds().then(id => {
+      this._identities = id ?? [];
+    });
+  }
+
+  _computeIdentity(id: string) {
+    return id && id.startsWith('mailto:') ? '' : id;
+  }
+
+  _computeHideDeleteClass(canDelete?: boolean) {
+    return canDelete ? 'show' : '';
+  }
+
+  _handleDeleteItemConfirm() {
+    this.$.overlay.close();
+    return this.$.restAPI.deleteAccountIdentity([this._idName!]).then(() => {
+      this.loadData();
+    });
+  }
+
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteItem(e: PolymerDomRepeatEvent<AccountExternalIdInfo>) {
+    const name = e.model.item.identity;
+    if (!name) {
+      return;
+    }
+    this._idName = name;
+    this.$.overlay.open();
+  }
+
+  _computeIsTrusted(item?: boolean) {
+    return item ? '' : 'Untrusted';
+  }
+
+  filterIdentities(item: AccountExternalIdInfo) {
+    return !item.identity.startsWith('username:');
+  }
+
+  _computeShowLinkAnotherIdentity(config?: ServerInfo) {
+    if (config?.auth?.git_basic_auth_policy) {
+      return AUTH.includes(config.auth.git_basic_auth_policy.toUpperCase());
+    }
+
+    return false;
+  }
+
+  _computeLinkAnotherIdentity() {
+    const baseUrl = getBaseUrl() || '';
+    let pathname = window.location.pathname;
+    if (baseUrl) {
+      pathname = '/' + pathname.substring(baseUrl.length);
+    }
+    return baseUrl + '/login/' + encodeURIComponent(pathname) + '?link';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-identities': 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
deleted file mode 100644
index bf50124..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
new file mode 100644
index 0000000..1472103
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 0965826..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
+++ /dev/null
@@ -1,190 +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">
-<meta charset="utf-8">
-<title>gr-identities</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-identities></gr-identities>
-  </template>
-</test-fixture>
-
-<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();
-
-    stub('gr-rest-api-interface', {
-      getExternalIds() { return Promise.resolve(ids); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('renders', () => {
-    const rows = Array.from(
-        dom(element.root).querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[2].textContent
-    );
-
-    assert.equal(nameCells[0].trim(), 'gerrit:gerrit');
-    assert.equal(nameCells[1].trim(), '');
-  });
-
-  test('renders email', () => {
-    const rows = Array.from(
-        dom(element.root).querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[1].textContent
-    );
-
-    assert.equal(nameCells[0], 'gerrit@example.com');
-    assert.equal(nameCells[1], 'gerrit2@example.com');
-  });
-
-  test('_computeIdentity', () => {
-    assert.equal(
-        element._computeIdentity(ids[0].identity), 'username:john');
-    assert.equal(element._computeIdentity(ids[2].identity), '');
-  });
-
-  test('filterIdentities', () => {
-    assert.isFalse(element.filterIdentities(ids[0]));
-
-    assert.isTrue(element.filterIdentities(ids[1]));
-  });
-
-  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-identities/gr-identities_test.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
new file mode 100644
index 0000000..8af5bd0
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
@@ -0,0 +1,169 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-identities.js';
+
+const basicFixture = fixtureFromElement('gr-identities');
+
+suite('gr-identities tests', () => {
+  let element;
+
+  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 => {
+    stub('gr-rest-api-interface', {
+      getExternalIds() { return Promise.resolve(ids); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = Array.from(
+        element.root.querySelectorAll('tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td')[2].textContent
+    );
+
+    assert.equal(nameCells[0].trim(), 'gerrit:gerrit');
+    assert.equal(nameCells[1].trim(), '');
+  });
+
+  test('renders email', () => {
+    const rows = Array.from(
+        element.root.querySelectorAll('tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td')[1].textContent
+    );
+
+    assert.equal(nameCells[0], 'gerrit@example.com');
+    assert.equal(nameCells[1], 'gerrit2@example.com');
+  });
+
+  test('_computeIdentity', () => {
+    assert.equal(
+        element._computeIdentity(ids[0].identity), 'username:john');
+    assert.equal(element._computeIdentity(ids[2].identity), '');
+  });
+
+  test('filterIdentities', () => {
+    assert.isFalse(element.filterIdentities(ids[0]));
+
+    assert.isTrue(element.filterIdentities(ids[1]));
+  });
+
+  test('delete id', done => {
+    element._idName = 'mailto:gerrit2@example.com';
+    const loadDataStub = sinon.stub(element, 'loadData');
+    element._handleDeleteItemConfirm().then(() => {
+      assert.isTrue(loadDataStub.called);
+      done();
+    });
+  });
+
+  test('_handleDeleteItem opens modal', () => {
+    const deleteBtn =
+        element.root.querySelector('.deleteButton');
+    const deleteItem = sinon.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);
+  });
+});
+
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
deleted file mode 100644
index 42982fd..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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(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(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(dom(e).localTarget.dataset.index);
-    this.splice('menuItems', index, 1);
-  }
-
-  _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._newName = '';
-    this._newUrl = '';
-  }
-
-  _computeAddDisabled(newName, newUrl) {
-    return !newName.length || !newUrl.length;
-  }
-
-  _handleInputKeydown(e) {
-    if (e.keyCode === 13) {
-      e.stopPropagation();
-      this._handleAddButton();
-    }
-  }
-}
-
-customElements.define(GrMenuEditor.is, GrMenuEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
new file mode 100644
index 0000000..0498b35
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -0,0 +1,110 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../../styles/gr-form-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-menu-editor_html';
+import {customElement, property} from '@polymer/decorators';
+import {TopMenuItemInfo} from '../../../types/common';
+
+@customElement('gr-menu-editor')
+export class GrMenuEditor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array})
+  menuItems!: TopMenuItemInfo[];
+
+  @property({type: String})
+  _newName?: string;
+
+  @property({type: String})
+  _newUrl?: string;
+
+  _handleMoveUpButton(e: Event) {
+    const target = (dom(e) as EventApi).localTarget;
+    if (!(target instanceof HTMLElement)) return;
+    const index = Number(target.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: Event) {
+    const target = (dom(e) as EventApi).localTarget;
+    if (!(target instanceof HTMLElement)) return;
+    const index = Number(target.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: Event) {
+    const target = (dom(e) as EventApi).localTarget;
+    if (!(target instanceof HTMLElement)) return;
+    const index = Number(target.dataset['index']);
+    this.splice('menuItems', index, 1);
+  }
+
+  _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._newName = '';
+    this._newUrl = '';
+  }
+
+  _computeAddDisabled(newName?: string, newUrl?: string) {
+    return !newName?.length || !newUrl?.length;
+  }
+
+  _handleInputKeydown(e: KeyboardEvent) {
+    if (e.keyCode === 13) {
+      e.stopPropagation();
+      this._handleAddButton();
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-menu-editor': 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
deleted file mode 100644
index ceb8958..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
new file mode 100644
index 0000000..e4d66e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 9c8db6d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ /dev/null
@@ -1,178 +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">
-<meta charset="utf-8">
-<title>gr-settings-view</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-menu-editor></gr-menu-editor>
-  </template>
-</test-fixture>
-
-<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 => 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.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);
-    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-menu-editor/gr-menu-editor_test.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
new file mode 100644
index 0000000..19852d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-menu-editor.js';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-menu-editor');
+
+suite('gr-menu-editor tests', () => {
+  let element;
+  let menu;
+
+  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.shadowRoot
+            .querySelector('tbody').querySelector(selector)
+            .shadowRoot
+            .querySelector('paper-button');
+    MockInteractions.tap(button);
+  }
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    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']);
+  });
+});
+
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
deleted file mode 100644
index 6635de2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrRegistrationDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-registration-dialog'; }
-  /**
-   * Fired when account details are changed.
-   *
-   * @event account-detail-update
-   */
-
-  /**
-   * 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.
-          return {email: null, name: null, username: null};
-        },
-      },
-      _usernameMutable: {
-        type: Boolean,
-        computed: '_computeUsernameMutable(_serverConfig, _account.username)',
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-        observer: '_loadingChanged',
-      },
-      _saving: {
-        type: Boolean,
-        value: false,
-      },
-      _serverConfig: Object,
-    };
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
-  }
-
-  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 loadConfig = this.$.restAPI.getConfig().then(config => {
-      this._serverConfig = config;
-    });
-
-    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 || ''),
-    ];
-
-    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,
-      }));
-    });
-  }
-
-  _handleSave(e) {
-    e.preventDefault();
-    this._save().then(this.close.bind(this));
-  }
-
-  _handleClose(e) {
-    e.preventDefault();
-    this.close();
-  }
-
-  close() {
-    this._saving = true; // disable buttons indefinitely
-    this.dispatchEvent(new CustomEvent('close', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _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;
-    }
-
-    return config.auth.editable_account_fields.includes('USER_NAME') &&
-        !username;
-  }
-
-  _computeUsernameClass(usernameMutable) {
-    return usernameMutable ? '' : 'hide';
-  }
-
-  _loadingChanged() {
-    this.classList.toggle('loading', this._loading);
-  }
-}
-
-customElements.define(GrRegistrationDialog.is, GrRegistrationDialog);
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
new file mode 100644
index 0000000..0e73062
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -0,0 +1,177 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-registration-dialog_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {ServerInfo, AccountDetailInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EditableAccountField} from '../../../constants/constants';
+
+export interface GrRegistrationDialog {
+  $: {
+    restAPI: RestApiService & Element;
+    name: HTMLInputElement;
+    username: HTMLInputElement;
+    email: HTMLSelectElement;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-registration-dialog': GrRegistrationDialog;
+  }
+}
+
+@customElement('gr-registration-dialog')
+export class GrRegistrationDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when account details are changed.
+   *
+   * @event account-detail-update
+   */
+
+  /**
+   * Fired when the close button is pressed.
+   *
+   * @event close
+   */
+  @property({type: String})
+  settingsUrl?: string;
+
+  @property({type: Object})
+  _account: Partial<AccountDetailInfo> = {};
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean})
+  _saving = false;
+
+  @property({type: Object})
+  _serverConfig?: ServerInfo;
+
+  @property({
+    computed: '_computeUsernameMutable(_serverConfig,_account.username)',
+    type: Boolean,
+  })
+  _usernameMutable = false;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
+
+  _computeUsernameMutable(config?: ServerInfo, username?: string) {
+    // Polymer 2: check for undefined
+    // username is not being checked for undefined as we want to avoid
+    // setting it null explicitly to trigger the computation
+    if (config === undefined) {
+      return false;
+    }
+
+    return (
+      config.auth.editable_account_fields.includes(
+        EditableAccountField.USER_NAME
+      ) && !username
+    );
+  }
+
+  loadData() {
+    this._loading = true;
+
+    const loadAccount = this.$.restAPI.getAccount().then(account => {
+      this._account = {...this._account, ...account};
+    });
+
+    const loadConfig = this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
+    });
+
+    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 || ''),
+    ];
+
+    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,
+        })
+      );
+    });
+  }
+
+  _handleSave(e: Event) {
+    e.preventDefault();
+    this._save().then(() => this.close());
+  }
+
+  _handleClose(e: Event) {
+    e.preventDefault();
+    this.close();
+  }
+
+  close() {
+    this._saving = true; // disable buttons indefinitely
+    this.dispatchEvent(
+      new CustomEvent('close', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _computeSaveDisabled(name?: string, email?: string, saving?: boolean) {
+    return !name || !email || saving;
+  }
+
+  _computeUsernameClass(usernameMutable: boolean) {
+    return usernameMutable ? '' : 'hide';
+  }
+
+  @observe('_loading')
+  _loadingChanged() {
+    this.classList.toggle('loading', this._loading);
+  }
+}
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
deleted file mode 100644
index 3559ba6..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
new file mode 100644
index 0000000..11fbbc9
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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
deleted file mode 100644
index a3f8548..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
+++ /dev/null
@@ -1,186 +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">
-<meta charset="utf-8">
-<title>gr-registration-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-registration-dialog></gr-registration-dialog>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<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 = {};
-
-    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();
-  });
-
-  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 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 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';
-
-      // 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'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
new file mode 100644
index 0000000..468ef57
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
@@ -0,0 +1,163 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../test/common-test-setup-karma.js';
+import './gr-registration-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-registration-dialog');
+
+suite('gr-registration-dialog tests', () => {
+  let element;
+  let account;
+
+  let _listeners;
+
+  setup(() => {
+    _listeners = {};
+
+    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 = basicFixture.instantiate();
+
+    return element.loadData();
+  });
+
+  teardown(() => {
+    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 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 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';
+
+      // 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'));
+  });
+});
+
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
deleted file mode 100644
index 3884a15..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
+++ /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.
- */
-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';
-
-/** @extends Polymer.Element */
-class GrSettingsItem extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
new file mode 100644
index 0000000..5d80a84d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
@@ -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.
+ */
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-settings-item_html';
+import {property, customElement} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-settings-item': GrSettingsItem;
+  }
+}
+
+@customElement('gr-settings-item')
+class GrSettingsItem extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  anchor?: string;
+
+  @property({type: String})
+  title = '';
+}
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
deleted file mode 100644
index e26faab..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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-item_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.ts
new file mode 100644
index 0000000..786abc0
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.ts
@@ -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';
+
+export const htmlTemplate = html`
+  <style>
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+  </style>
+  <h2 id="[[anchor]]" class="heading-2">[[title]]</h2>
+  <slot></slot>
+`;
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
deleted file mode 100644
index 5b11516..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
+++ /dev/null
@@ -1,41 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
new file mode 100644
index 0000000..e288d20
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
@@ -0,0 +1,43 @@
+/**
+ * @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 '../../../styles/gr-page-nav-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-settings-menu-item_html';
+import {property, customElement} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-settings-menu-item': GrSettingsMenuItem;
+  }
+}
+
+@customElement('gr-settings-menu-item')
+class GrSettingsMenuItem extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  href?: string;
+
+  @property({type: String})
+  title = '';
+}
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
deleted file mode 100644
index 95433ac..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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-menu-item_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.ts
new file mode 100644
index 0000000..fc3edcd
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.ts
@@ -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';
+
+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.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
deleted file mode 100644
index 158c5eb..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ /dev/null
@@ -1,511 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 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 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 RELOAD_MESSAGE = 'Reloading...';
-
-const HTTP_AUTH = [
-  'HTTP',
-  'HTTP_LDAP',
-];
-
-/**
- * @extends Polymer.Element
- */
-class GrSettingsView extends mixinBehaviors( [
-  DocsUrlBehavior,
-  ChangeTableBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-settings-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
-   * Fired with email confirmation text, or when the page reloads.
-   *
-   * @event show-alert
-   */
-
-  static get properties() {
-    return {
-      prefs: {
-        type: Object,
-        value() { return {}; },
-      },
-      params: {
-        type: Object,
-        value() { return {}; },
-      },
-      _accountInfoChanged: Boolean,
-      _changeTableColumnsNotDisplayed: Array,
-      /** @type {?} */
-      _localPrefs: {
-        type: Object,
-        value() { return {}; },
-      },
-      _localChangeTableColumns: {
-        type: Array,
-        value() { return []; },
-      },
-      _localMenu: {
-        type: Array,
-        value() { return []; },
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _changeTableChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _prefsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      _diffPrefsChanged: Boolean,
-      /** @type {?} */
-      _editPrefsChanged: Boolean,
-      _menuChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _watchedProjectsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _keysChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _gpgKeysChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _newEmail: String,
-      _addingEmail: {
-        type: Boolean,
-        value: false,
-      },
-      _lastSentVerificationEmail: {
-        type: String,
-        value: null,
-      },
-      /** @type {?} */
-      _serverConfig: Object,
-      /** @type {?string} */
-      _docsBaseUrl: String,
-      _emailsChanged: Boolean,
-
-      /**
-       * For testing purposes.
-       */
-      _loadingPromise: Object,
-
-      _showNumber: Boolean,
-
-      _isDark: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handlePrefsChanged(_localPrefs.*)',
-      '_handleMenuChanged(_localMenu.splices)',
-      '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    // Polymer 2: anchor tag won't work on shadow DOM
-    // we need to manually calling scrollIntoView when hash changed
-    this.listen(window, 'location-change', '_handleLocationChange');
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Settings'},
-      composed: true, bubbles: true,
-    }));
-
-    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(),
-    ];
-
-    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.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.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
new file mode 100644
index 0000000..9f9840b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -0,0 +1,578 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import {
+  applyTheme as applyDarkTheme,
+  removeTheme as removeDarkTheme,
+} from '../../../styles/themes/dark-theme';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../gr-change-table-editor/gr-change-table-editor';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-diff-preferences/gr-diff-preferences';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../gr-account-info/gr-account-info';
+import '../gr-agreements-list/gr-agreements-list';
+import '../gr-edit-preferences/gr-edit-preferences';
+import '../gr-email-editor/gr-email-editor';
+import '../gr-gpg-editor/gr-gpg-editor';
+import '../gr-group-list/gr-group-list';
+import '../gr-http-password/gr-http-password';
+import '../gr-identities/gr-identities';
+import '../gr-menu-editor/gr-menu-editor';
+import '../gr-ssh-editor/gr-ssh-editor';
+import '../gr-watched-projects-editor/gr-watched-projects-editor';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-settings-view_html';
+import {getDocsBaseUrl} from '../../../utils/url-util';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {customElement, property, observe} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {GrAccountInfo} from '../gr-account-info/gr-account-info';
+import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
+import {GrGroupList} from '../gr-group-list/gr-group-list';
+import {GrIdentities} from '../gr-identities/gr-identities';
+import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
+import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  PreferencesInput,
+  ServerInfo,
+  TopMenuItemInfo,
+} from '../../../types/common';
+import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
+import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
+import {GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
+  '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 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'];
+
+enum CopyPrefsDirection {
+  PrefsToLocalPrefs,
+  LocalPrefsToPrefs,
+}
+
+type LocalMenuItemInfo = Omit<TopMenuItemInfo, 'id'>;
+
+export interface GrSettingsView {
+  $: {
+    restAPI: RestApiService & Element;
+    accountInfo: GrAccountInfo;
+    watchedProjectsEditor: GrWatchedProjectsEditor;
+    groupList: GrGroupList;
+    identities: GrIdentities;
+    editPrefs: GrEditPreferences;
+    diffPrefs: GrDiffPreferences;
+    sshEditor: GrSshEditor;
+    gpgEditor: GrGpgEditor;
+    emailEditor: GrEmailEditor;
+    insertSignedOff: HTMLInputElement;
+    workInProgressByDefault: HTMLInputElement;
+    showSizeBarsInFileList: HTMLInputElement;
+    publishCommentsOnPush: HTMLInputElement;
+    relativeDateInChangeTable: HTMLInputElement;
+  };
+}
+
+@customElement('gr-settings-view')
+export class GrSettingsView extends ChangeTableMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired with email confirmation text, or when the page reloads.
+   *
+   * @event show-alert
+   */
+
+  @property({type: Object})
+  prefs: PreferencesInput = {};
+
+  @property({type: Object})
+  params?: AppElementParams;
+
+  @property({type: Boolean})
+  _accountInfoChanged?: boolean;
+
+  @property({type: Array})
+  _changeTableColumnsNotDisplayed?: string[];
+
+  @property({type: Object})
+  _localPrefs: PreferencesInput = {};
+
+  @property({type: Array})
+  _localChangeTableColumns: string[] = [];
+
+  @property({type: Array})
+  _localMenu: LocalMenuItemInfo[] = [];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean})
+  _changeTableChanged = false;
+
+  @property({type: Boolean})
+  _prefsChanged = false;
+
+  @property({type: Boolean})
+  _diffPrefsChanged?: boolean;
+
+  @property({type: Boolean})
+  _editPrefsChanged?: boolean;
+
+  @property({type: Boolean})
+  _menuChanged = false;
+
+  @property({type: Boolean})
+  _watchedProjectsChanged = false;
+
+  @property({type: Boolean})
+  _keysChanged = false;
+
+  @property({type: Boolean})
+  _gpgKeysChanged = false;
+
+  @property({type: String})
+  _newEmail?: string;
+
+  @property({type: Boolean})
+  _addingEmail = false;
+
+  @property({type: String})
+  _lastSentVerificationEmail?: string | null = null;
+
+  @property({type: Object})
+  _serverConfig?: ServerInfo;
+
+  @property({type: String})
+  _docsBaseUrl?: string | null;
+
+  @property({type: Boolean})
+  _emailsChanged?: boolean;
+
+  @property({type: Boolean})
+  _showNumber?: boolean;
+
+  @property({type: Boolean})
+  _isDark = false;
+
+  public _testOnly_loadingPromise?: Promise<void>;
+
+  /** @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');
+
+    const promises: Array<Promise<unknown>> = [
+      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 => {
+        if (!prefs) {
+          throw new Error('getPreferences returned undefined');
+        }
+        this.prefs = prefs;
+        this._showNumber = !!prefs.legacycid_in_change_table;
+        this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+        this._cloneMenu(prefs.my);
+        this._cloneChangeTableColumns(prefs.change_table);
+      })
+    );
+
+    promises.push(
+      this.$.restAPI.getConfig().then(config => {
+        this._serverConfig = config;
+        const configPromises: Array<Promise<void>> = [];
+
+        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(
+          getDocsBaseUrl(config, this.$.restAPI).then(baseUrl => {
+            this._docsBaseUrl = baseUrl;
+          })
+        );
+
+        return Promise.all(configPromises);
+      })
+    );
+
+    if (
+      this.params &&
+      this.params.view === GerritView.SETTINGS &&
+      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._testOnly_loadingPromise = Promise.all(promises).then(() => {
+      this._loading = false;
+
+      // Handle anchor tag for initial load
+      this._handleLocationChange();
+    });
+  }
+
+  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(direction: CopyPrefsDirection) {
+    let to;
+    let from;
+    if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
+      from = this._localPrefs;
+      to = 'prefs';
+    } else {
+      from = this.prefs;
+      to = '_localPrefs';
+    }
+    for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
+      this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+    }
+  }
+
+  _cloneMenu(prefs: TopMenuItemInfo[]) {
+    const menu = [];
+    for (const item of prefs) {
+      menu.push({
+        name: item.name,
+        url: item.url,
+        target: item.target,
+      });
+    }
+    this._localMenu = menu;
+  }
+
+  _cloneChangeTableColumns(changeTable: string[]) {
+    let columns = this.getVisibleColumns(changeTable);
+
+    if (columns.length === 0) {
+      columns = this.columnNames;
+      this._changeTableColumnsNotDisplayed = [];
+    } else {
+      this._changeTableColumnsNotDisplayed = this.getComplementColumns(
+        changeTable
+      );
+    }
+    this._localChangeTableColumns = columns;
+  }
+
+  @observe('_localChangeTableColumns', '_showNumber')
+  _handleChangeTableChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._changeTableChanged = true;
+  }
+
+  @observe('_localPrefs.*')
+  _handlePrefsChanged() {
+    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);
+  }
+
+  @observe('_localMenu.splices')
+  _handleMenuChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._menuChanged = true;
+  }
+
+  _handleSaveAccountInfo() {
+    this.$.accountInfo.save();
+  }
+
+  _handleSavePreferences() {
+    this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
+
+    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(this._localChangeTableColumns);
+    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._localMenu);
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._menuChanged = false;
+    });
+  }
+
+  _handleResetMenuButton() {
+    return this.$.restAPI.getDefaultPreferences().then(data => {
+      if (data?.my) {
+        this._cloneMenu(data.my);
+      }
+    });
+  }
+
+  _handleSaveWatchedProjects() {
+    this.$.watchedProjectsEditor.save();
+  }
+
+  _computeHeaderClass(changed?: boolean) {
+    return changed ? 'edited' : '';
+  }
+
+  _handleSaveEmails() {
+    this.$.emailEditor.save();
+  }
+
+  _handleNewEmailKeydown(e: CustomKeyboardEvent) {
+    if (e.keyCode === 13) {
+      // Enter
+      e.stopPropagation();
+      this._handleAddEmailButton();
+    }
+  }
+
+  _isNewEmailValid(newEmail?: string): newEmail is string {
+    return !!newEmail && newEmail.includes('@');
+  }
+
+  _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
+    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?: string) {
+    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');
+      removeDarkTheme();
+    } else {
+      window.localStorage.setItem('dark-theme', 'true');
+      applyDarkTheme();
+    }
+    this._isDark = !!window.localStorage.getItem('dark-theme');
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {
+          message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
+        },
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+
+  _showHttpAuth(config?: ServerInfo) {
+    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: Event) {
+    e.preventDefault();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-settings-view': 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
deleted file mode 100644
index e92bc68..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
+++ /dev/null
@@ -1,537 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
new file mode 100644
index 0000000..78f84b1
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -0,0 +1,556 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      color: var(--primary-text-color);
+    }
+    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);
+    }
+    .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 class="heading-1">User Settings</h1>
+      <section class="darkToggle">
+        <div class="toggle">
+          <paper-toggle-button
+            aria-labelledby="darkThemeToggleLabel"
+            checked="[[_isDark]]"
+            on-change="_handleToggleDark"
+            on-tap="_onTapDarkToggle"
+          ></paper-toggle-button>
+          <div id="darkThemeToggleLabel">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="ATTENTION_SET_ONLY"
+                  >Only when I am in the attention set</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 requesting my attention</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Attention: <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
deleted file mode 100644
index e430ecb..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ /dev/null
@@ -1,532 +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">
-<meta charset="utf-8">
-<title>gr-settings-view</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-settings-view></gr-settings-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<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');
-      }
-    }
-  }
-
-  // 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',
-        () => 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,
-
-      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);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('calls the title-change event', () => {
-    const titleChangedStub = sandbox.stub();
-
-    // Create a new view.
-    const newElement = document.createElement('gr-settings-view');
-    newElement.addEventListener('title-change', titleChangedStub);
-
-    // Attach it to the fixture.
-    const blank = fixture('blank');
-    blank.appendChild(newElement);
-
-    flush();
-
-    assert.isTrue(titleChangedStub.called);
-    assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
-        'Settings');
-  });
-
-  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.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
-
-    // Change the diff view element.
-    const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
-    diffSelect.bindValue = 'SIDE_BY_SIDE';
-
-    const publishOnPush =
-        valueOf('Publish comments on push', 'preferences').firstElementChild;
-    diffSelect.dispatchEvent(
-        new CustomEvent('change', {
-          composed: true, bubbles: true,
-        }));
-
-    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 =
-      valueOf('Publish comments on push', 'preferences').firstElementChild;
-    MockInteractions.tap(publishCommentsOnPush);
-
-    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();
-      },
-    });
-
-    // 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.prefs.my, element._localMenu);
-      done();
-    });
-  });
-
-  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));
-  });
-
-  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 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 = [
-      {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();
-    });
-  });
-
-  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-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
new file mode 100644
index 0000000..0535e15
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -0,0 +1,523 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
+import './gr-settings-view.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-settings-view');
+const blankFixture = fixtureFromElement('div');
+
+suite('gr-settings-view tests', () => {
+  let element;
+  let account;
+  let preferences;
+  let config;
+
+  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);
+    }
+  }
+
+  function stubAddAccountEmail(statusCode) {
+    return sinon.stub(element.$.restAPI, 'addAccountEmail').callsFake(
+        () => Promise.resolve({status: statusCode}));
+  }
+
+  setup(done => {
+    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: []}};
+
+    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 = basicFixture.instantiate();
+
+    // Allow the element to render.
+    element._testOnly_loadingPromise.then(done);
+  });
+
+  test('theme changing', () => {
+    window.localStorage.removeItem('dark-theme');
+    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
+    const themeToggle = element.shadowRoot
+        .querySelector('.darkToggle paper-toggle-button');
+    MockInteractions.tap(themeToggle);
+    assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
+    assert.equal(
+        getComputedStyleValue('--primary-text-color', document.body), '#e8eaed'
+    );
+    MockInteractions.tap(themeToggle);
+    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
+  });
+
+  test('calls the title-change event', () => {
+    const titleChangedStub = sinon.stub();
+
+    // Create a new view.
+    const newElement = document.createElement('gr-settings-view');
+    newElement.addEventListener('title-change', titleChangedStub);
+
+    const blank = blankFixture.instantiate();
+    blank.appendChild(newElement);
+
+    flush();
+
+    assert.isTrue(titleChangedStub.called);
+    assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
+        'Settings');
+  });
+
+  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.isFalse(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
+
+    // Change the diff view element.
+    const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
+    diffSelect.bindValue = 'SIDE_BY_SIDE';
+
+    const publishOnPush =
+        valueOf('Publish comments on push', 'preferences').firstElementChild;
+    diffSelect.dispatchEvent(
+        new CustomEvent('change', {
+          composed: true, bubbles: true,
+        }));
+
+    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 =
+      valueOf('Publish comments on push', 'preferences').firstElementChild;
+    MockInteractions.tap(publishCommentsOnPush);
+
+    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();
+      },
+    });
+
+    // 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 = 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 = 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();
+    });
+  });
+
+  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));
+  });
+
+  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 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', () => {
+    sinon.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 = sinon.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 = [
+      {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();
+    });
+  });
+
+  test('test that reset button is called', () => {
+    const overlayOpen = sinon.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(() => {
+      sinon.stub(element.$.emailEditor, 'loadData');
+      sinon.stub(
+          element.$.restAPI,
+          'confirmEmail')
+          .callsFake(
+              () => new Promise(
+                  resolve => { resolveConfirm = resolve; }));
+      element.params = {view: GerritView.SETTINGS, 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._testOnly_loadingPromise.then(() => {
+        assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+        done();
+      });
+      resolveConfirm();
+    });
+
+    test('show-alert is fired when email is confirmed', done => {
+      sinon.spy(element, 'dispatchEvent');
+      element._testOnly_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');
+    });
+  });
+});
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
deleted file mode 100644
index 814eb7a..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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,
-        notify: true,
-      },
-      _keys: Array,
-      /** @type {?} */
-      _keyToView: Object,
-      _newKey: {
-        type: String,
-        value: '',
-      },
-      _keysToRemove: {
-        type: Array,
-        value() { return []; },
-      },
-    };
-  }
-
-  loadData() {
-    return this.$.restAPI.getAccountSSHKeys().then(keys => {
-      this._keys = keys;
-    });
-  }
-
-  save() {
-    const promises = this._keysToRemove.map(key => {
-      this.$.restAPI.deleteAccountSSHKey(key.seq);
-    });
-
-    return Promise.all(promises).then(() => {
-      this._keysToRemove = [];
-      this.hasUnsavedChanges = false;
-    });
-  }
-
-  _getStatusLabel(isValid) {
-    return isValid ? 'Valid' : 'Invalid';
-  }
-
-  _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.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;
-  }
-}
-
-customElements.define(GrSshEditor.is, GrSshEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
new file mode 100644
index 0000000..507caef
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -0,0 +1,132 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-form-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-ssh-editor_html';
+import {property, customElement} from '@polymer/decorators';
+import {SshKeyInfo} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrSshEditor {
+  $: {
+    restAPI: RestApiService & Element;
+    addButton: GrButton;
+    newKey: IronAutogrowTextareaElement;
+    viewKeyOverlay: GrOverlay;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-ssh-editor': GrSshEditor;
+  }
+}
+@customElement('gr-ssh-editor')
+export class GrSshEditor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, notify: true})
+  hasUnsavedChanges = false;
+
+  @property({type: Array})
+  _keys: SshKeyInfo[] = [];
+
+  @property({type: Object})
+  _keyToView?: SshKeyInfo;
+
+  @property({type: String})
+  _newKey = '';
+
+  @property({type: Array})
+  _keysToRemove: SshKeyInfo[] = [];
+
+  loadData() {
+    return this.$.restAPI.getAccountSSHKeys().then(keys => {
+      if (!keys) return;
+      this._keys = keys;
+    });
+  }
+
+  save() {
+    const promises = this._keysToRemove.map(key =>
+      this.$.restAPI.deleteAccountSSHKey(`${key.seq}`)
+    );
+    return Promise.all(promises).then(() => {
+      this._keysToRemove = [];
+      this.hasUnsavedChanges = false;
+    });
+  }
+
+  _getStatusLabel(isValid: boolean) {
+    return isValid ? 'Valid' : 'Invalid';
+  }
+
+  _showKey(e: Event) {
+    const el = (dom(e) as EventApi).localTarget as GrButton;
+    const index = Number(el.getAttribute('data-index')!);
+    this._keyToView = this._keys[index];
+    this.$.viewKeyOverlay.open();
+  }
+
+  _closeOverlay() {
+    this.$.viewKeyOverlay.close();
+  }
+
+  _handleDeleteKey(e: Event) {
+    const el = (dom(e) as EventApi).localTarget as GrButton;
+    const index = Number(el.getAttribute('data-index')!);
+    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;
+      });
+  }
+
+  _computeAddButtonDisabled(newKey: string) {
+    return !newKey.length;
+  }
+}
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
deleted file mode 100644
index 1f3c793..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js
+++ /dev/null
@@ -1,143 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
new file mode 100644
index 0000000..96f770a
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 56625ae..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
+++ /dev/null
@@ -1,181 +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">
-<meta charset="utf-8">
-<title>gr-ssh-editor</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-ssh-editor></gr-ssh-editor>
-  </template>
-</test-fixture>
-
-<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,
-    }];
-
-    stub('gr-rest-api-interface', {
-      getAccountSSHKeys() { return Promise.resolve(keys); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders', () => {
-    const rows = dom(element.root).querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 2);
-
-    let cells = rows[0].querySelectorAll('td');
-    assert.equal(cells[0].textContent, keys[0].comment);
-
-    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);
-      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-ssh-editor/gr-ssh-editor_test.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
new file mode 100644
index 0000000..29ba692
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
@@ -0,0 +1,166 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-ssh-editor.js';
+
+const basicFixture = fixtureFromElement('gr-ssh-editor');
+
+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,
+    }];
+
+    stub('gr-rest-api-interface', {
+      getAccountSSHKeys() { return Promise.resolve(keys); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = element.root.querySelectorAll('tbody tr');
+
+    assert.equal(rows.length, 2);
+
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, keys[0].comment);
+
+    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')
+        .callsFake(() => Promise.resolve());
+
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = 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 = 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').callsFake(
+        () => 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').callsFake(
+        () => 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);
+  });
+});
+
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
deleted file mode 100644
index b8960e8..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
+++ /dev/null
@@ -1,194 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-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'},
-];
-
-/** @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,
-        notify: true,
-      },
-
-      _projects: Array,
-      _projectsToRemove: {
-        type: Array,
-        value() { return []; },
-      },
-      _query: {
-        type: Function,
-        value() {
-          return this._getProjectSuggestions.bind(this);
-        },
-      },
-    };
-  }
-
-  loadData() {
-    return this.$.restAPI.getWatchedProjects().then(projs => {
-      this._projects = projs;
-    });
-  }
-
-  save() {
-    let deletePromise;
-    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 true;
-  }
-
-  _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;
-  }
-
-  _handleAddProject() {
-    const newProject = this.$.newProject.value;
-    const newProjectName = this.$.newProject.text;
-    const filter = this.$.newFilter.value || null;
-
-    if (!this._canAddProject(newProject, newProjectName, filter)) { return; }
-
-    const insertIndex = this._getNewProjectIndex(newProjectName, filter);
-
-    this.splice('_projects', insertIndex, 0, {
-      project: newProjectName,
-      filter,
-      _is_local: true,
-    });
-
-    this.$.newProject.clear();
-    this.$.newFilter.bindValue = '';
-    this.hasUnsavedChanges = 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;
-  }
-
-  _handleNotifCellClick(e) {
-    const checkbox = 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.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
new file mode 100644
index 0000000..15f9c6b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -0,0 +1,258 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-watched-projects-editor_html';
+import {customElement, property} from '@polymer/decorators';
+import {
+  AutocompleteQuery,
+  GrAutocomplete,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {ProjectWatchInfo} from '../../../types/common';
+
+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'},
+];
+
+export interface GrWatchedProjectsEditor {
+  $: {
+    restAPI: RestApiService & Element;
+    newFilter: HTMLInputElement;
+    newProject: GrAutocomplete;
+  };
+}
+@customElement('gr-watched-projects-editor')
+export class GrWatchedProjectsEditor extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, notify: true})
+  hasUnsavedChanges = false;
+
+  @property({type: Array})
+  _projects?: ProjectWatchInfo[];
+
+  @property({type: Array})
+  _projectsToRemove: ProjectWatchInfo[] = [];
+
+  @property({type: Object})
+  _query?: AutocompleteQuery;
+
+  constructor() {
+    super();
+    this._query = input => this._getProjectSuggestions(input);
+  }
+
+  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(undefined);
+    }
+
+    return deletePromise
+      .then(() => {
+        if (this._projects) {
+          return this.$.restAPI.saveWatchedProjects(this._projects);
+        } else {
+          return Promise.resolve(undefined);
+        }
+      })
+      .then(projects => {
+        this._projects = projects;
+        this._projectsToRemove = [];
+        this.hasUnsavedChanges = false;
+      });
+  }
+
+  _getTypes() {
+    return NOTIFICATION_TYPES;
+  }
+
+  _getTypeCount() {
+    return this._getTypes().length;
+  }
+
+  _computeCheckboxChecked(project: ProjectWatchInfo, key: string) {
+    return hasOwnProperty(project, key);
+  }
+
+  _getProjectSuggestions(input: string) {
+    return this.$.restAPI.getSuggestedProjects(input).then(response => {
+      const projects: AutocompleteSuggestion[] = [];
+      for (const key in response) {
+        if (!hasOwnProperty(response, key)) {
+          continue;
+        }
+        projects.push({
+          name: key,
+          value: response[key].id,
+        });
+      }
+      return projects;
+    });
+  }
+
+  _handleRemoveProject(e: Event) {
+    const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
+    const dataIndex = el.getAttribute('data-index');
+    if (dataIndex === null || !this._projects) return;
+    const index = Number(dataIndex);
+    const project = this._projects[index];
+    this.splice('_projects', index, 1);
+    this.push('_projectsToRemove', project);
+    this.hasUnsavedChanges = true;
+  }
+
+  _canAddProject(
+    project: string | null,
+    text: string | null,
+    filter: string | null
+  ) {
+    if (project === null && text === null) {
+      return false;
+    }
+
+    // This will only be used if not using the auto complete
+    if (!project && text) {
+      return true;
+    }
+
+    if (!this._projects) return true;
+    // Check if the project with filter is already in the list.
+    for (let i = 0; i < this._projects.length; i++) {
+      if (
+        this._projects[i].project === project &&
+        this.areFiltersEqual(this._projects[i].filter, filter)
+      ) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  _getNewProjectIndex(name: string, filter: string | null) {
+    if (!this._projects) return;
+    let i;
+    for (i = 0; i < this._projects.length; i++) {
+      const projectFilter = this._projects[i].filter;
+      if (
+        this._projects[i].project > name ||
+        (this._projects[i].project === name &&
+          this.isFilterDefined(projectFilter) &&
+          this.isFilterDefined(filter) &&
+          projectFilter! > filter!)
+      ) {
+        break;
+      }
+    }
+    return i;
+  }
+
+  _handleAddProject() {
+    const newProject = this.$.newProject.value;
+    const newProjectName = this.$.newProject.text;
+    const filter = this.$.newFilter.value || null;
+
+    if (!this._canAddProject(newProject, newProjectName, filter)) {
+      return;
+    }
+
+    const insertIndex = this._getNewProjectIndex(newProjectName, filter);
+
+    if (insertIndex !== undefined) {
+      this.splice('_projects', insertIndex, 0, {
+        project: newProjectName,
+        filter,
+        _is_local: true,
+      });
+    }
+
+    this.$.newProject.clear();
+    this.$.newFilter.value = '';
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleCheckboxChange(e: Event) {
+    const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
+    if (el === null) return;
+    const dataIndex = el.getAttribute('data-index');
+    const key = el.getAttribute('data-key');
+    if (dataIndex === null || key === null) return;
+    const index = Number(dataIndex);
+    const checked = el.checked;
+    this.set(['_projects', index, key], !!checked);
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleNotifCellClick(e: Event) {
+    if (e.target === null) return;
+    const checkbox = (e.target as HTMLElement).querySelector('input');
+    if (checkbox) {
+      checkbox.click();
+    }
+  }
+
+  isFilterDefined(filter: string | null | undefined) {
+    return filter !== null && filter !== undefined;
+  }
+
+  areFiltersEqual(
+    filter1: string | null | undefined,
+    filter2: string | null | undefined
+  ) {
+    // null and undefined are equal
+    if (!this.isFilterDefined(filter1) && !this.isFilterDefined(filter2)) {
+      return true;
+    }
+    return filter1 === filter2;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-watched-projects-editor': 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
deleted file mode 100644
index b1ca653..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
new file mode 100644
index 0000000..e3a90a2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 2a08e4f..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ /dev/null
@@ -1,215 +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">
-<meta charset="utf-8">
-<title>gr-settings-view</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-watched-projects-editor></gr-watched-projects-editor>
-  </template>
-</test-fixture>
-
-<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,
-      },
-    ];
-
-    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); });
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
-    assert.equal(rows.length, 4);
-
-    function getKeysOfRow(row) {
-      const boxes = rows[row].querySelectorAll('input[checked]');
-      return Array.prototype.map.call(boxes,
-          e => e.getAttribute('data-key'));
-    }
-
-    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(1);
-    assert.equal(checkedKeys.length, 1);
-    assert.equal(checkedKeys[0], 'notify_new_changes');
-
-    checkedKeys = getKeysOfRow(2);
-    assert.equal(checkedKeys.length, 0);
-
-    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 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/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
new file mode 100644
index 0000000..2fd8900
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
@@ -0,0 +1,200 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-watched-projects-editor.js';
+
+const basicFixture = fixtureFromElement('gr-watched-projects-editor');
+
+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,
+      },
+    ];
+
+    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 = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
+    assert.equal(rows.length, 4);
+
+    function getKeysOfRow(row) {
+      const boxes = rows[row].querySelectorAll('input[checked]');
+      return Array.prototype.map.call(boxes,
+          e => e.getAttribute('data-key'));
+    }
+
+    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(1);
+    assert.equal(checkedKeys.length, 1);
+    assert.equal(checkedKeys[0], 'notify_new_changes');
+
+    checkedKeys = getKeysOfRow(2);
+    assert.equal(checkedKeys.length, 0);
+
+    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 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));
+
+    // Can add a project that is not in the list.
+    assert.isTrue(element._canAddProject('project d', null, null));
+    assert.isTrue(element._canAddProject('project d', null, 'filter 3'));
+
+    // Cannot add a project that is in the list with no filter.
+    assert.isFalse(element._canAddProject('project a', null, null));
+
+    // Can add a project that is in the list if the filter differs.
+    assert.isTrue(element._canAddProject('project a', null, 'filter 4'));
+
+    // Cannot add a project that is in the list with the same filter.
+    assert.isFalse(element._canAddProject('project b', null, 'filter 1'));
+    assert.isFalse(element._canAddProject('project b', null, 'filter 2'));
+
+    // Can add a project that is in the list using a new filter.
+    assert.isTrue(element._canAddProject('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 = '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 = '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);
+
+    flush();
+
+    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');
+  });
+});
+
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
deleted file mode 100644
index 6ceee26..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrAccountChip extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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
-   */
-
-  static get properties() {
-    return {
-      account: Object,
-      voteableText: String,
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      removable: {
-        type: Boolean,
-        value: false,
-      },
-      showAttention: {
-        type: Boolean,
-        value: false,
-      },
-      showAvatar: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      transparentBackground: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._getHasAvatars().then(hasAvatars => {
-      this.showAvatar = hasAvatars;
-    });
-  }
-
-  _getBackgroundClass(transparent) {
-    return transparent ? 'transparentBackground' : '';
-  }
-
-  _handleRemoveTap(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('remove', {
-      detail: {account: this.account},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _getHasAvatars() {
-    return this.$.restAPI.getConfig()
-        .then(cfg => Promise.resolve(!!(
-          cfg && cfg.plugin && cfg.plugin.has_avatars
-        )));
-  }
-}
-
-customElements.define(GrAccountChip.is, GrAccountChip);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
new file mode 100644
index 0000000..9623442
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -0,0 +1,133 @@
+/**
+ * @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 '../gr-account-link/gr-account-link';
+import '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-account-chip_html';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrAccountChip {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-account-chip')
+export 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
+   */
+
+  /**
+   * 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
+   */
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  /**
+   * Optional ChangeInfo object, typically comes from the change page or
+   * from a row in a list of search results. This is needed for some change
+   * related features like adding the user as a reviewer.
+   */
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  /**
+   * Should this user be considered to be in the attention set, regardless
+   * of the current state of the change object?
+   */
+  @property({type: Boolean})
+  forceAttention = false;
+
+  @property({type: String})
+  voteableText?: string;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  removable = false;
+
+  /**
+   * Should attention set related features be shown in the component? Note
+   * that the information whether the user is in the attention set or not is
+   * part of the ChangeInfo object in the change property.
+   */
+  @property({type: Boolean})
+  highlightAttention = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  showAvatar?: boolean;
+
+  @property({type: Boolean})
+  transparentBackground = false;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._getHasAvatars().then(hasAvatars => {
+      this.showAvatar = hasAvatars;
+    });
+  }
+
+  _getBackgroundClass(transparent: boolean) {
+    return transparent ? 'transparentBackground' : '';
+  }
+
+  _handleRemoveTap(e: MouseEvent) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('remove', {
+        detail: {account: this.account},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _getHasAvatars() {
+    return this.$.restAPI
+      .getConfig()
+      .then(cfg =>
+        Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars))
+      );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-chip': 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
deleted file mode 100644
index 96e0160..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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-chip/gr-account-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
new file mode 100644
index 0000000..991104f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      overflow: hidden;
+    }
+    .container {
+      align-items: center;
+      background-color: var(--background-color-primary);
+      /** round */
+      border-radius: var(--account-chip-border-radius, 20px);
+      border: 1px solid var(--border-color);
+      display: inline-flex;
+      padding: 0 1px;
+
+      --account-label-padding-horizontal: 6px;
+      --gr-account-label-text-style: {
+        color: var(--deemphasized-text-color);
+      }
+    }
+    :host([show-avatar]) .container {
+    }
+    :host([removable]) .container {
+    }
+    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;
+        /* This cancels most of the --account-label-padding-horizontal. */
+        margin-left: -4px;
+        padding: 0 2px 0 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;
+    }
+    :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]]"
+      change="[[change]]"
+      force-attention="[[forceAttention]]"
+      highlight-attention="[[highlightAttention]]"
+      voteable-text="[[voteableText]]"
+    >
+    </gr-account-link>
+    <gr-button
+      id="remove"
+      link=""
+      hidden$="[[!removable]]"
+      hidden=""
+      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.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
deleted file mode 100644
index c991a37..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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
-   */
-
-  /**
-   * 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
-   */
-
-  static get properties() {
-    return {
-      allowAnyInput: Boolean,
-      borderless: Boolean,
-      placeholder: String,
-
-      // suggestFrom = 0 to enable default suggestions.
-      suggestFrom: {
-        type: Number,
-        value: 0,
-      },
-
-      /** @type {!function(string): !Promise<Array<{name, value}>>} */
-      querySuggestions: {
-        type: Function,
-        notify: true,
-        value() {
-          return input => Promise.resolve([]);
-        },
-      },
-
-      _config: Object,
-      /** The value of the autocomplete entry. */
-      _inputText: {
-        type: String,
-        observer: '_inputTextChanged',
-      },
-
-    };
-  }
-
-  get focusStart() {
-    return this.$.input.focusStart;
-  }
-
-  focus() {
-    this.$.input.focus();
-  }
-
-  clear() {
-    this.$.input.clear();
-  }
-
-  setText(text) {
-    this.$.input.setText(text);
-  }
-
-  getText() {
-    return this.$.input.text;
-  }
-
-  _handleInputCommit(e) {
-    this.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}));
-    }
-  }
-}
-
-customElements.define(GrAccountEntry.is, GrAccountEntry);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
new file mode 100644
index 0000000..925480f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -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.
+ */
+import '../../../styles/shared-styles';
+import '../gr-autocomplete/gr-autocomplete';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-account-entry_html';
+import {customElement, property} from '@polymer/decorators';
+import {GrAutocomplete} from '../gr-autocomplete/gr-autocomplete';
+
+export interface GrAccountEntry {
+  $: {
+    input: GrAutocomplete;
+  };
+}
+/**
+ * gr-account-entry is an element for entering account
+ * and/or group with autocomplete support.
+ */
+@customElement('gr-account-entry')
+export class GrAccountEntry extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * 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
+   */
+
+  @property({type: Boolean})
+  allowAnyInput?: boolean;
+
+  @property({type: Boolean})
+  borderless?: boolean;
+
+  @property({type: String})
+  placeholder?: string;
+
+  @property({type: Number})
+  suggestFrom = 0;
+
+  @property({type: Object, notify: true})
+  querySuggestions = () => Promise.resolve([]);
+
+  @property({type: String, observer: '_inputTextChanged'})
+  _inputText?: string;
+
+  get focusStart() {
+    return this.$.input.focusStart;
+  }
+
+  focus() {
+    this.$.input.focus();
+  }
+
+  clear() {
+    this.$.input.clear();
+  }
+
+  setText(text: string) {
+    this.$.input.setText(text);
+  }
+
+  getText() {
+    return this.$.input.text;
+  }
+
+  _handleInputCommit(e: CustomEvent) {
+    this.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: e.detail.value},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    this.$.input.focus();
+  }
+
+  _inputTextChanged(text: string) {
+    if (text.length && this.allowAnyInput) {
+      this.dispatchEvent(
+        new CustomEvent('account-text-changed', {bubbles: true, composed: true})
+      );
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-entry': 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
deleted file mode 100644
index afd427a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
new file mode 100644
index 0000000..c6c2b7f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 5899ad4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
+++ /dev/null
@@ -1,109 +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">
-<meta charset="utf-8">
-<title>gr-account-entry</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-account-entry></gr-account-entry>
-  </template>
-</test-fixture>
-
-<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',
-  };
-
-  setup(done => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    return flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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-entry/gr-account-entry_test.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
new file mode 100644
index 0000000..4430c65
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-entry.js';
+
+const basicFixture = fixtureFromElement('gr-account-entry');
+
+suite('gr-account-entry tests', () => {
+  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',
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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 = sinon.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 = sinon.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 = sinon.spy(element.$.input, 'query');
+    element.setText('test text');
+    flush();
+
+    assert.equal(element.$.input.$.input.value, 'test text');
+    assert.isFalse(suggestSpy.called);
+  });
+});
+
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
deleted file mode 100644
index 110d884..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ /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.
- */
-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-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';
-
-/**
- * @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,
-      voteableText: String,
-      showAttention: {
-        type: Boolean,
-        value: false,
-      },
-      hideAvatar: {
-        type: Boolean,
-        value: false,
-      },
-      hideStatus: {
-        type: Boolean,
-        value: false,
-      },
-      _serverConfig: {
-        type: Object,
-        value: null,
-      },
-    };
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this.$.restAPI.getConfig()
-        .then(config => { this._serverConfig = config; });
-  }
-
-  _computeName(account, config) {
-    return this.getDisplayName(config, account);
-  }
-}
-
-customElements.define(GrAccountLabel.is, GrAccountLabel);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
new file mode 100644
index 0000000..5852135
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -0,0 +1,287 @@
+/**
+ * @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 '@polymer/iron-icon/iron-icon';
+import '../../../styles/shared-styles';
+import '../gr-avatar/gr-avatar';
+import '../gr-hovercard-account/gr-hovercard-account';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-account-label_html';
+import {appContext} from '../../../services/app-context';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {isServiceUser} from '../../../utils/account-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+export interface GrAccountLabel {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-account-label')
+export class GrAccountLabel extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  account!: AccountInfo;
+
+  @property({type: Object})
+  _selfAccount?: AccountInfo;
+
+  /**
+   * Optional ChangeInfo object, typically comes from the change page or
+   * from a row in a list of search results. This is needed for some change
+   * related features like adding the user as a reviewer.
+   */
+  @property({type: Object})
+  change!: ChangeInfo;
+
+  @property({type: String})
+  voteableText?: string;
+
+  /**
+   * Should this user be considered to be in the attention set, regardless
+   * of the current state of the change object?
+   */
+  @property({type: Boolean})
+  forceAttention = false;
+
+  /**
+   * Only show the first name in the account label.
+   */
+  @property({type: Boolean})
+  firstName = false;
+
+  /**
+   * Should attention set related features be shown in the component? Note
+   * that the information whether the user is in the attention set or not is
+   * part of the ChangeInfo object in the change property.
+   */
+  @property({type: Boolean})
+  highlightAttention = false;
+
+  @property({type: Boolean})
+  hideHovercard = false;
+
+  @property({type: Boolean})
+  hideAvatar = false;
+
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+    computed:
+      '_computeCancelLeftPadding(hideAvatar, _config, ' +
+      'highlightAttention, account, change, forceAttention)',
+  })
+  cancelLeftPadding = false;
+
+  @property({type: Boolean})
+  hideStatus = false;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  reporting: ReportingService;
+
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+    this.$.restAPI.getAccount().then(account => {
+      this._selfAccount = account;
+    });
+    this.addEventListener('attention-set-updated', () => {
+      // For re-evaluation of everything that depends on 'change'.
+      this.change = {...this.change};
+    });
+  }
+
+  _isAttentionSetEnabled(
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo
+  ) {
+    return (
+      !!config &&
+      !!config.change &&
+      !!config.change.enable_attention_set &&
+      !!highlight &&
+      !!change &&
+      !!account &&
+      !isServiceUser(account)
+    );
+  }
+
+  _computeCancelLeftPadding(
+    hideAvatar: boolean,
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo,
+    force: boolean
+  ) {
+    const hasAvatars = !!config?.plugin?.has_avatars;
+    return (
+      !hideAvatar &&
+      !this._hasAttention(config, highlight, account, change, force) &&
+      hasAvatars
+    );
+  }
+
+  _hasAttention(
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo,
+    force: boolean
+  ) {
+    return (
+      force || this._hasUnforcedAttention(config, highlight, account, change)
+    );
+  }
+
+  _hasUnforcedAttention(
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo
+  ) {
+    return (
+      this._isAttentionSetEnabled(config, highlight, account, change) &&
+      change.attention_set &&
+      !!account._account_id &&
+      hasOwnProperty(change.attention_set, account._account_id)
+    );
+  }
+
+  _computeHasAttentionClass(
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo,
+    force: boolean
+  ) {
+    return this._hasAttention(config, highlight, account, change, force)
+      ? 'hasAttention'
+      : '';
+  }
+
+  _computeName(
+    account?: AccountInfo,
+    config?: ServerInfo,
+    firstName?: boolean
+  ) {
+    return getDisplayName(config, account, firstName);
+  }
+
+  _handleRemoveAttentionClick(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    if (!this.account._account_id) return;
+
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {
+          message: 'Saving attention set update ...',
+          dismissOnNavigation: true,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    // We are deliberately updating the UI before making the API call. It is a
+    // risk that we are taking to achieve a better UX for 99.9% of the cases.
+    const selfName = getDisplayName(this._config, this._selfAccount);
+    const reason = `Removed by ${selfName} by clicking the attention icon`;
+    if (this.change.attention_set)
+      delete this.change.attention_set[this.account._account_id];
+    // For re-evaluation of everything that depends on 'change'.
+    this.change = {...this.change};
+
+    this.reporting.reportInteraction(
+      'attention-icon-remove',
+      this._reportingDetails()
+    );
+    this.$.restAPI
+      .removeFromAttentionSet(
+        this.change._number,
+        this.account._account_id,
+        reason
+      )
+      .then(() => {
+        this.dispatchEvent(
+          new CustomEvent('hide-alert', {bubbles: true, composed: true})
+        );
+      });
+  }
+
+  _reportingDetails() {
+    const targetId = this.account._account_id;
+    const ownerId =
+      (this.change && this.change.owner && this.change.owner._account_id) || -1;
+    const selfId = this._selfAccount?._account_id || -1;
+    const reviewers =
+      this.change && this.change.reviewers && this.change.reviewers.REVIEWER
+        ? [...this.change.reviewers.REVIEWER]
+        : [];
+    const reviewerIds = reviewers
+      .map(r => r._account_id)
+      .filter(rId => rId !== ownerId);
+    return {
+      actionByOwner: selfId === ownerId,
+      actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
+      targetIsOwner: targetId === ownerId,
+      targetIsReviewer: reviewerIds.includes(targetId),
+      targetIsSelf: targetId === selfId,
+    };
+  }
+
+  _computeAttentionIconTitle(
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo
+  ) {
+    return this._hasUnforcedAttention(config, highlight, account, change)
+      ? 'Click to remove the user from the attention set'
+      : 'Disabled. Use "Modify" to make changes.';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-label': 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
deleted file mode 100644
index ba2d9cb..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
new file mode 100644
index 0000000..1d8b13e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+      vertical-align: top;
+      position: relative;
+      border-radius: var(--label-border-radius);
+      max-width: var(--account-max-length, 200px);
+      box-sizing: border-box;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      padding: 0 var(--account-label-padding-horizontal, 0);
+    }
+    /* If the first element is the avatar, then we cancel the left padding, so
+       we can fit nicely into the gr-account-chip rounding.
+       The obvious alternative of 'chip has padding' and 'avatar gets negative
+       margin' does not work, because we need 'overflow:hidden' on the label. */
+    :host([cancel-left-padding]) {
+      padding-left: 0;
+    }
+    :host::after {
+      content: var(--account-label-suffix);
+    }
+    :host([deselected]) {
+      background-color: var(--background-color-primary);
+      border: 1px solid var(--comment-separator-color);
+      border-radius: 8px;
+      color: var(--deemphasized-text-color);
+    }
+    :host([selected]) {
+      background-color: var(--chip-selected-background-color);
+      border: 1px solid var(--chip-selected-background-color);
+      border-radius: 8px;
+      color: var(--chip-selected-text-color);
+    }
+    :host([selected]) iron-icon.attention {
+      color: var(--chip-selected-text-color);
+    }
+    gr-avatar {
+      height: calc(var(--line-height-normal) - 2px);
+      width: calc(var(--line-height-normal) - 2px);
+      vertical-align: top;
+      position: relative;
+      top: 1px;
+    }
+    .text {
+      @apply --gr-account-label-text-style;
+    }
+    .text:hover {
+      @apply --gr-account-label-text-hover-style;
+    }
+    #attentionButton {
+      /* This negates the 4px horizontal padding, which we appreciate as a
+         larger click target, but which we don't want to consume space. :-) */
+      margin: 0 -4px 0 -4px;
+      vertical-align: top;
+    }
+    iron-icon.attention {
+      width: 12px;
+      height: 12px;
+    }
+    iron-icon.status {
+      width: 14px;
+      height: 14px;
+      vertical-align: top;
+      position: relative;
+      top: 2px;
+    }
+    .hasAttention .name {
+      font-weight: var(--font-weight-bold);
+    }
+  </style>
+  <span>
+    <template is="dom-if" if="[[!hideHovercard]]">
+      <gr-hovercard-account
+        for="hovercardTarget"
+        account="[[account]]"
+        change="[[change]]"
+        highlight-attention="[[highlightAttention]]"
+        voteable-text="[[voteableText]]"
+      >
+      </gr-hovercard-account>
+    </template>
+    <template
+      is="dom-if"
+      if="[[_hasAttention(_config, highlightAttention, account, change, forceAttention)]]"
+    >
+      <gr-button
+        id="attentionButton"
+        link=""
+        aria-label="Remove user from attention set"
+        on-click="_handleRemoveAttentionClick"
+        disabled="[[!_hasUnforcedAttention(_config, highlightAttention, account, change)]]"
+        has-tooltip="[[_hasUnforcedAttention(_config, highlightAttention, account, change)]]"
+        title="[[_computeAttentionIconTitle(_config, highlightAttention, account, change)]]"
+        ><iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
+      </gr-button>
+    </template>
+  </span>
+  <span
+    id="hovercardTarget"
+    class$="[[_computeHasAttentionClass(_config, highlightAttention, account, change, forceAttention)]]"
+  >
+    <template is="dom-if" if="[[!hideAvatar]]">
+      <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
+    </template>
+    <span class="text">
+      <span class="name">[[_computeName(account, _config, firstName)]]</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
deleted file mode 100644
index 4cc66c4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ /dev/null
@@ -1,94 +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">
-<meta charset="utf-8">
-<title>gr-account-label</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-account-label></gr-account-label>
-  </template>
-</test-fixture>
-
-<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 = {
-      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('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-label/gr-account-label_test.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
new file mode 100644
index 0000000..2e54db2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
@@ -0,0 +1,112 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-label.js';
+
+const basicFixture = fixtureFromElement('gr-account-label');
+
+suite('gr-account-label tests', () => {
+  let element;
+
+  function createAccount(name, id) {
+    return {name, _account_id: id};
+  }
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = basicFixture.instantiate();
+    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('test for anonymous_coward_name', () => {
+      const config = {
+        user: {
+          anonymous_coward_name: 'TestAnon',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config),
+          'TestAnon');
+    });
+  });
+
+  suite('attention set', () => {
+    setup(() => {
+      element.highlightAttention = true;
+      element._config = {
+        change: {enable_attention_set: true},
+        user: {anonymous_coward_name: 'Anonymous Coward'},
+      };
+      element._selfAccount = createAccount('kermit', 31);
+      element.account = createAccount('ernie', 42);
+      element.change = {attention_set: {42: {}}};
+      flush();
+    });
+
+    test('show attention button', () => {
+      assert.ok(element.shadowRoot.querySelector('#attentionButton'));
+    });
+
+    test('tap attention button', () => {
+      const apiStub = sinon.stub(element.$.restAPI, 'removeFromAttentionSet')
+          .callsFake(() => Promise.resolve());
+      const button = element.shadowRoot.querySelector('#attentionButton');
+      assert.ok(button);
+      MockInteractions.tap(button);
+      assert.isTrue(apiStub.calledOnce);
+      assert.equal(apiStub.lastCall.args[1], 42);
+    });
+  });
+});
+
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
deleted file mode 100644
index 27de4b3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ /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.
- */
-
-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';
-
-/**
- * @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,
-      showAttention: {
-        type: Boolean,
-        value: false,
-      },
-      hideAvatar: {
-        type: Boolean,
-        value: false,
-      },
-      hideStatus: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  _computeOwnerLink(account) {
-    if (!account) { return; }
-    return GerritNav.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.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
new file mode 100644
index 0000000..be54f4e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
@@ -0,0 +1,94 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../gr-account-label/gr-account-label';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-account-link_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
+
+@customElement('gr-account-link')
+class GrAccountLink extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  voteableText?: string;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  /**
+   * Optional ChangeInfo object, typically comes from the change page or
+   * from a row in a list of search results. This is needed for some change
+   * related features like adding the user as a reviewer.
+   */
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  /**
+   * Should this user be considered to be in the attention set, regardless
+   * of the current state of the change object?
+   */
+  @property({type: Boolean})
+  forceAttention = false;
+
+  /**
+   * Should attention set related features be shown in the component? Note
+   * that the information whether the user is in the attention set or not is
+   * part of the ChangeInfo object in the change property.
+   */
+  @property({type: Boolean})
+  highlightAttention = false;
+
+  @property({type: Boolean})
+  hideAvatar = false;
+
+  @property({type: Boolean})
+  hideStatus = false;
+
+  /**
+   * Only show the first name in the account label.
+   */
+  @property({type: Boolean})
+  firstName = false;
+
+  _computeOwnerLink(account?: AccountInfo) {
+    if (!account) {
+      return;
+    }
+    return GerritNav.getUrlForOwner(
+      account.email ||
+        account.username ||
+        account.name ||
+        `${account._account_id}`
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-link': 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
deleted file mode 100644
index 17e7f49..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
new file mode 100644
index 0000000..be4db01
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+      vertical-align: top;
+    }
+    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)]]">
+      <gr-account-label
+        account="[[account]]"
+        change="[[change]]"
+        force-attention="[[forceAttention]]"
+        highlight-attention="[[highlightAttention]]"
+        hide-avatar="[[hideAvatar]]"
+        hide-status="[[hideStatus]]"
+        first-name="[[firstName]]"
+        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
deleted file mode 100644
index f3bff6e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ /dev/null
@@ -1,81 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-account-link</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-account-link></gr-account-link>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-account-link.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-account-link 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('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-link/gr-account-link_test.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
new file mode 100644
index 0000000..554953e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-link.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-account-link');
+
+suite('gr-account-link tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('computed fields', () => {
+    const url = 'test/url';
+    const urlStub = sinon.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'));
+  });
+});
+
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
deleted file mode 100644
index 73ccf7d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ /dev/null
@@ -1,359 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const VALID_EMAIL_ALERT = 'Please input a valid email.';
-
-/**
- * @extends Polymer.Element
- */
-class GrAccountList extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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 []; },
-        notify: true,
-      },
-      change: Object,
-      filter: Function,
-      placeholder: String,
-      disabled: {
-        type: Function,
-        value: false,
-      },
-
-      /**
-       * Returns suggestions and convert them to list item
-       *
-       * @type {Gerrit.GrSuggestionsProvider}
-       */
-      suggestionsProvider: {
-        type: Object,
-      },
-
-      /**
-       * Needed for template checking since value is initially set to null.
-       *
-       * @type {?Object}
-       */
-      pendingConfirmation: {
-        type: Object,
-        value: null,
-        notify: true,
-      },
-      readonly: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * When true, allows for non-suggested inputs to be added.
-       */
-      allowAnyInput: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Array of values (groups/accounts) that are removable. When this prop is
-       * undefined, all values are removable.
-       */
-      removableValues: Array,
-      maxCount: {
-        type: Number,
-        value: 0,
-      },
-
-      /**
-       * Returns suggestion items
-       *
-       * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
-       */
-      _querySuggestions: {
-        type: Function,
-        value() {
-          return this._getSuggestions.bind(this);
-        },
-      },
-
-      /**
-       * Set to true to disable suggestions on empty input.
-       */
-      skipSuggestOnEmpty: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('remove',
-        e => this._handleRemove(e));
-  }
-
-  get accountChips() {
-    return Array.from(
-        dom(this.root).querySelectorAll('gr-account-chip'));
-  }
-
-  get focusStart() {
-    return this.$.entry.focusStart;
-  }
-
-  _getSuggestions(input) {
-    if (this.skipSuggestOnEmpty && !input) {
-      return Promise.resolve([]);
-    }
-    const provider = this.suggestionsProvider;
-    if (!provider) {
-      return Promise.resolve([]);
-    }
-    return provider.getSuggestions(input).then(suggestions => {
-      if (!suggestions) { return []; }
-      if (this.filter) {
-        suggestions = suggestions.filter(this.filter);
-      }
-      return suggestions.map(suggestion =>
-        provider.makeSuggestionItem(suggestion));
-    });
-  }
-
-  _handleAdd(e) {
-    this._addAccountItem(e.detail.value);
-  }
-
-  _addAccountItem(item) {
-    // Append new account or group to the accounts property. We add our own
-    // 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);
-      }
-    }
-    this.pendingConfirmation = null;
-    return true;
-  }
-
-  confirmGroup(group) {
-    group = Object.assign(
-        {}, group, {confirmed: true, _pendingAdd: true, _group: true});
-    this.push('accounts', group);
-    this.pendingConfirmation = null;
-  }
-
-  _computeChipClass(account) {
-    const classes = [];
-    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 !!account._pendingAdd;
-    }
-    return true;
-  }
-
-  _handleRemove(e) {
-    const toRemove = e.detail.account;
-    this._removeAccount(toRemove);
-    this.$.entry.focus();
-  }
-
-  _removeAccount(toRemove) {
-    if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
-      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);
-      }
-      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
-        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 => 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.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
new file mode 100644
index 0000000..c5e71fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -0,0 +1,469 @@
+/**
+ * @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 '../gr-account-chip/gr-account-chip';
+import '../gr-account-entry/gr-account-entry';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-account-list_html';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  Suggestion,
+  AccountInfo,
+  GroupInfo,
+} from '../../../types/common';
+import {
+  GrReviewerSuggestionsProvider,
+  SuggestionItem,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
+import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {PaperInputElementExt} from '../../../types/types';
+
+const VALID_EMAIL_ALERT = 'Please input a valid email.';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-account-list': GrAccountList;
+  }
+}
+
+export interface GrAccountList {
+  $: {
+    entry: GrAccountEntry;
+  };
+}
+
+/**
+ * For item added with account info
+ */
+export interface AccountObjectInput {
+  account: AccountInfo;
+}
+
+/**
+ * For item added with group info
+ */
+export interface GroupObjectInput {
+  group: GroupInfo;
+  confirm: boolean;
+}
+
+/** Supported input to be added */
+export type RawAccountInput = string | AccountObjectInput | GroupObjectInput;
+
+// type guards for AccountObjectInput and GroupObjectInput
+function isAccountObject(x: RawAccountInput): x is AccountObjectInput {
+  return !!(x as AccountObjectInput).account;
+}
+
+function isGroupObjectInput(x: RawAccountInput): x is GroupObjectInput {
+  return !!(x as GroupObjectInput).group;
+}
+
+// Internal input type with account info
+export interface AccountInfoInput extends AccountInfo {
+  _group?: boolean;
+  _account?: boolean;
+  _pendingAdd?: boolean;
+  confirmed?: boolean;
+}
+
+// Internal input type with group info
+export interface GroupInfoInput extends GroupInfo {
+  _group?: boolean;
+  _account?: boolean;
+  _pendingAdd?: boolean;
+  confirmed?: boolean;
+}
+
+function isAccountInfoInput(x: AccountInput): x is AccountInfoInput {
+  const input = x as AccountInfoInput;
+  return !!input._account || !!input._account_id || !!input.email;
+}
+
+function isGroupInfoInput(x: AccountInput): x is GroupInfoInput {
+  const input = x as GroupInfoInput;
+  return !!input._group || !!input.id;
+}
+
+type AccountInput = AccountInfoInput | GroupInfoInput;
+
+export interface AccountAddition {
+  account?: AccountInfoInput;
+  group?: GroupInfoInput;
+}
+
+@customElement('gr-account-list')
+export class GrAccountList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when user inputs an invalid email address.
+   *
+   * @event show-alert
+   */
+
+  @property({type: Array, notify: true})
+  accounts: AccountInput[] = [];
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  filter?: (input: Suggestion) => boolean;
+
+  @property({type: String})
+  placeholder = '';
+
+  @property({type: Boolean})
+  disabled = false;
+
+  /**
+   * Returns suggestions and convert them to list item
+   */
+  @property({type: Object})
+  suggestionsProvider?: GrReviewerSuggestionsProvider;
+
+  /**
+   * Needed for template checking since value is initially set to null.
+   */
+  @property({type: Object, notify: true})
+  pendingConfirmation: GroupObjectInput | null = null;
+
+  @property({type: Boolean})
+  readonly = false;
+
+  /**
+   * When true, allows for non-suggested inputs to be added.
+   */
+  @property({type: Boolean})
+  allowAnyInput = false;
+
+  /**
+   * Array of values (groups/accounts) that are removable. When this prop is
+   * undefined, all values are removable.
+   */
+  @property({type: Array})
+  removableValues?: AccountInput[];
+
+  @property({type: Number})
+  maxCount = 0;
+
+  /**
+   * Returns suggestion items
+   */
+  @property({type: Object})
+  _querySuggestions: (input: string) => Promise<SuggestionItem[]>;
+
+  /**
+   * Set to true to disable suggestions on empty input.
+   */
+  @property({type: Boolean})
+  skipSuggestOnEmpty = false;
+
+  reporting: ReportingService;
+
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+    this._querySuggestions = input => this._getSuggestions(input);
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('remove', e =>
+      this._handleRemove(e as CustomEvent<{account: AccountInput}>)
+    );
+  }
+
+  get accountChips() {
+    return Array.from(this.root?.querySelectorAll('gr-account-chip') || []);
+  }
+
+  get focusStart() {
+    return this.$.entry.focusStart;
+  }
+
+  _getSuggestions(input: string) {
+    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 suggestions.map(suggestion =>
+        provider.makeSuggestionItem(suggestion)
+      );
+    });
+  }
+
+  _handleAdd(e: CustomEvent<{value: RawAccountInput}>) {
+    this.addAccountItem(e.detail.value);
+  }
+
+  addAccountItem(item: RawAccountInput) {
+    // 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.
+    let itemTypeAdded = 'unknown';
+    if (isAccountObject(item)) {
+      const account = {...item.account, _pendingAdd: true};
+      this.push('accounts', account);
+      itemTypeAdded = 'account';
+    } else if (isGroupObjectInput(item)) {
+      if (item.confirm) {
+        this.pendingConfirmation = item;
+        return;
+      }
+      const group = {...item.group, _pendingAdd: true, _group: true};
+      this.push('accounts', group);
+      itemTypeAdded = '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);
+        itemTypeAdded = 'email';
+      }
+    }
+
+    this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
+    this.pendingConfirmation = null;
+    return true;
+  }
+
+  confirmGroup(group: GroupInfo) {
+    this.push('accounts', {
+      ...group,
+      confirmed: true,
+      _pendingAdd: true,
+      _group: true,
+    });
+    this.pendingConfirmation = null;
+  }
+
+  _computeChipClass(account: AccountInput) {
+    const classes = [];
+    if (account._group) {
+      classes.push('group');
+    }
+    if (account._pendingAdd) {
+      classes.push('pendingAdd');
+    }
+    return classes.join(' ');
+  }
+
+  _accountMatches(a: AccountInput, b: AccountInput) {
+    // TODO(TS): seems a & b always exists ?
+    if (a && b) {
+      // both conditions are checking against AccountInfo
+      // and only check a not b.. typeguard won't work very good without
+      // changing logic, so keep it as inline casting
+      if ((a as AccountInfoInput)._account_id) {
+        return (
+          (a as AccountInfoInput)._account_id ===
+          (b as AccountInfoInput)._account_id
+        );
+      }
+      if ((a as AccountInfoInput).email) {
+        return (a as AccountInfoInput).email === (b as AccountInfoInput).email;
+      }
+    }
+    return a === b;
+  }
+
+  _computeRemovable(account: AccountInput, readonly: boolean) {
+    if (readonly) {
+      return false;
+    }
+    if (this.removableValues) {
+      for (let i = 0; i < this.removableValues.length; i++) {
+        if (this._accountMatches(this.removableValues[i], account)) {
+          return true;
+        }
+      }
+      return !!account._pendingAdd;
+    }
+    return true;
+  }
+
+  _handleRemove(e: CustomEvent<{account: AccountInput}>) {
+    const toRemove = e.detail.account;
+    this.removeAccount(toRemove);
+    this.$.entry.focus();
+  }
+
+  removeAccount(toRemove?: AccountInput) {
+    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 as GroupInfoInput).id === (account as GroupInfoInput).id;
+      } else {
+        matches = this._accountMatches(toRemove, account);
+      }
+      if (matches) {
+        this.splice('accounts', i, 1);
+        this.reporting.reportInteraction(`Remove from ${this.id}`);
+        return;
+      }
+    }
+    console.warn('received remove event for missing account', toRemove);
+  }
+
+  _getNativeInput(paperInput: PaperInputElementExt) {
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return (paperInput.$.nativeInput ||
+      paperInput.inputElement) as HTMLTextAreaElement;
+  }
+
+  _handleInputKeydown(
+    e: CustomEvent<{input: PaperInputElementExt; keyCode: number}>
+  ) {
+    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: KeyboardEvent) {
+    const chip = e.target as GrAccountChip;
+    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
+        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 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(): AccountAddition[] {
+    return this.accounts
+      .filter(account => account._pendingAdd)
+      .map(account => {
+        if (isGroupInfoInput(account)) {
+          return {group: account};
+        } else if (isAccountInfoInput(account)) {
+          return {account};
+        } else {
+          throw new Error('AccountInput must be either Account or Group.');
+        }
+      });
+  }
+
+  _computeEntryHidden(
+    maxCount: number,
+    accountsRecord: PolymerDeepPropertyChange<AccountInput[], AccountInput[]>,
+    readonly: boolean
+  ) {
+    return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
+  }
+}
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
deleted file mode 100644
index 6fee9f3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
new file mode 100644
index 0000000..2824bb5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
@@ -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';
+
+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
deleted file mode 100644
index b3b32606..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
+++ /dev/null
@@ -1,554 +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">
-<meta charset="utf-8">
-<title>gr-account-list</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-account-list></gr-account-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-account-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-class MockSuggestionsProvider {
-  getSuggestions(input) {
-    return Promise.resolve([]);
-  }
-
-  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,
-    };
-  };
-
-  let existingAccount1;
-  let existingAccount2;
-  let sandbox;
-  let element;
-  let suggestionsProvider;
-
-  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(() => {
-      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('_accountMatches', () => {
-    const acct = makeAccount();
-
-    assert.isTrue(element._accountMatches(acct, acct));
-    acct.email = 'test';
-    assert.isTrue(element._accountMatches(acct, acct));
-    assert.isTrue(element._accountMatches({email: 'test'}, acct));
-
-    assert.isFalse(element._accountMatches({}, acct));
-    assert.isFalse(element._accountMatches({email: 'test2'}, acct));
-    assert.isFalse(element._accountMatches({_account_id: -1}, acct));
-  });
-
-  suite('keyboard interactions', () => {
-    test('backspace at text input start removes last account', 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();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
new file mode 100644
index 0000000..3acba5b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
@@ -0,0 +1,520 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-list.js';
+
+const basicFixture = fixtureFromElement('gr-account-list');
+
+class MockSuggestionsProvider {
+  getSuggestions(input) {
+    return Promise.resolve([]);
+  }
+
+  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,
+    };
+  };
+
+  let existingAccount1;
+  let existingAccount2;
+
+  let element;
+  let suggestionsProvider;
+
+  function getChips() {
+    return element.root.querySelectorAll('gr-account-chip');
+  }
+
+  setup(() => {
+    existingAccount1 = makeAccount();
+    existingAccount2 = makeAccount();
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+    element.accounts = [existingAccount1, existingAccount2];
+    suggestionsProvider = new MockSuggestionsProvider();
+    element.suggestionsProvider = suggestionsProvider;
+  });
+
+  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', () => {
+    flush();
+    sinon.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,
+        },
+      },
+    });
+    flush();
+    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,
+        }));
+    flush();
+    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,
+        }));
+    flush();
+    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,
+        },
+      },
+    });
+    flush();
+    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,
+        }));
+    flush();
+    chips = getChips();
+    assert.equal(chips.length, 1);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+  });
+
+  test('_getSuggestions uses filter correctly', () => {
+    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,
+      },
+    ];
+    sinon.stub(suggestionsProvider, 'getSuggestions')
+        .returns(Promise.resolve(originalSuggestions));
+    sinon.stub(suggestionsProvider, 'makeSuggestionItem')
+        .callsFake( suggestion => {
+          return {
+            name: suggestion.email,
+            value: suggestion._account_id,
+          };
+        });
+
+    return 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;
+      };
+
+      return element._getSuggestions();
+    })
+        .then(suggestions => {
+          assert.deepEqual(suggestions,
+              [{name: originalSuggestions[0].email,
+                value: originalSuggestions[0]._account_id}]);
+        });
+  });
+
+  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;
+    flush();
+
+    const getTextStub = sinon.stub(element.$.entry, 'getText');
+    getTextStub.onFirstCall().returns('');
+    getTextStub.onSecondCall().returns('test');
+    getTextStub.onThirdCall().returns('test@test');
+
+    // When entry is empty, return true.
+    const clearStub = sinon.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,
+        },
+      },
+    });
+    flush();
+    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+  });
+
+  test('enter text calls suggestions provider', async () => {
+    const suggestions = [
+      {
+        email: 'abc@example.com',
+        text: 'abcd',
+      },
+      {
+        email: 'qwe@example.com',
+        text: 'qwer',
+      },
+    ];
+    const getSuggestionsStub =
+        sinon.stub(suggestionsProvider, 'getSuggestions')
+            .returns(Promise.resolve(suggestions));
+
+    const makeSuggestionItemStub =
+        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
+            .callsFake( item => item);
+
+    const input = element.$.entry.$.input;
+
+    input.text = 'newTest';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    await flush();
+    assert.isTrue(getSuggestionsStub.calledOnce);
+    assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
+    assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+  });
+
+  test('suggestion on empty', async () => {
+    element.skipSuggestOnEmpty = false;
+    const suggestions = [
+      {
+        email: 'abc@example.com',
+        text: 'abcd',
+      },
+      {
+        email: 'qwe@example.com',
+        text: 'qwer',
+      },
+    ];
+    const getSuggestionsStub =
+        sinon.stub(suggestionsProvider, 'getSuggestions')
+            .returns(Promise.resolve(suggestions));
+
+    const makeSuggestionItemStub =
+        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
+            .callsFake( item => item);
+
+    const input = element.$.entry.$.input;
+
+    input.text = '';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    await flush();
+    assert.isTrue(getSuggestionsStub.calledOnce);
+    assert.equal(getSuggestionsStub.lastCall.args[0], '');
+    assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+  });
+
+  test('skip suggestion on empty', async () => {
+    element.skipSuggestOnEmpty = true;
+    const getSuggestionsStub =
+        sinon.stub(suggestionsProvider, 'getSuggestions')
+            .returns(Promise.resolve([]));
+
+    const input = element.$.entry.$.input;
+
+    input.text = '';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    await flush();
+    assert.isTrue(getSuggestionsStub.notCalled);
+  });
+
+  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 = sinon.stub();
+      element.addEventListener('show-alert', toastHandler);
+      element._handleAdd({detail: {value: 'test'}});
+      assert.isTrue(toastHandler.called);
+    });
+  });
+
+  test('_accountMatches', () => {
+    const acct = makeAccount();
+
+    assert.isTrue(element._accountMatches(acct, acct));
+    acct.email = 'test';
+    assert.isTrue(element._accountMatches(acct, acct));
+    assert.isTrue(element._accountMatches({email: 'test'}, acct));
+
+    assert.isFalse(element._accountMatches({}, acct));
+    assert.isFalse(element._accountMatches({email: 'test2'}, acct));
+    assert.isFalse(element._accountMatches({_account_id: -1}, acct));
+  });
+
+  suite('keyboard interactions', () => {
+    test('backspace at text input start removes last account', async () => {
+      const input = element.$.entry.$.input;
+      sinon.stub(input, '_updateSuggestions');
+      sinon.stub(element, '_computeRemovable').returns(true);
+      await flush();
+      // Next line is a workaround for Firefox not moving cursor
+      // on input field update
+      assert.equal(
+          element._getNativeInput(input.$.input).selectionStart, 0);
+      input.text = 'test';
+      MockInteractions.focus(input.$.input);
+      flush();
+      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
+      flush();
+      assert.equal(element.accounts.length, 1);
+    });
+
+    test('arrow key navigation', async () => {
+      const input = element.$.entry.$.input;
+      input.text = '';
+      element.accounts = [makeAccount(), makeAccount()];
+      flush();
+      MockInteractions.focus(input.$.input);
+      await flush();
+      const chips = element.accountChips;
+      const chipsOneSpy = sinon.spy(chips[1], 'focus');
+      MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+      assert.isTrue(chipsOneSpy.called);
+      const chipsZeroSpy = sinon.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);
+    });
+
+    test('delete', () => {
+      element.accounts = [makeAccount(), makeAccount()];
+      flush();
+      const focusSpy = sinon.spy(element.accountChips[1], 'focus');
+      const removeSpy = sinon.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);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
deleted file mode 100644
index 1ec453e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @extends Polymer.Element */
-class GrAlert extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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,
-        readOnly: true,
-        reflectToAttribute: true,
-      },
-      toast: {
-        type: Boolean,
-        value: true,
-        reflectToAttribute: true,
-      },
-
-      _hideActionButton: Boolean,
-      _boundTransitionEndHandler: {
-        type: Function,
-        value() { return this._handleTransitionEnd.bind(this); },
-      },
-      _actionCallback: Function,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.addEventListener('transitionend', this._boundTransitionEndHandler);
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.removeEventListener('transitionend',
-        this._boundTransitionEndHandler);
-  }
-
-  show(text, opt_actionText, opt_actionCallback) {
-    this.text = text;
-    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()) {
-      getRootElement().removeChild(this);
-    }
-  }
-
-  _hasZeroTransitionDuration() {
-    const style = window.getComputedStyle(this);
-    // transitionDuration is always given in seconds.
-    const duration = Math.round(parseFloat(style.transitionDuration) * 100);
-    return duration === 0;
-  }
-
-  _handleTransitionEnd(e) {
-    if (this.shown) { return; }
-
-    getRootElement().removeChild(this);
-  }
-
-  _handleActionTap(e) {
-    e.preventDefault();
-    if (this._actionCallback) { this._actionCallback(); }
-  }
-}
-
-customElements.define(GrAlert.is, GrAlert);
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
new file mode 100644
index 0000000..e5806f0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -0,0 +1,129 @@
+/**
+ * @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 '../gr-button/gr-button';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-alert_html';
+import {getRootElement} from '../../../scripts/rootElement';
+import {customElement, property} from '@polymer/decorators';
+import {ErrorType} from '../../../types/types';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-alert': GrAlert;
+  }
+}
+
+@customElement('gr-alert')
+export class GrAlert extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the action button is pressed.
+   *
+   * @event action
+   */
+
+  @property({type: String})
+  text?: string;
+
+  @property({type: String})
+  actionText?: string;
+
+  @property({type: String})
+  type?: ErrorType;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  shown = true;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  toast = true;
+
+  @property({type: Boolean})
+  _hideActionButton?: boolean;
+
+  @property()
+  _boundTransitionEndHandler?: (
+    this: HTMLElement,
+    ev: TransitionEvent
+  ) => unknown;
+
+  @property()
+  _actionCallback?: () => void;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._boundTransitionEndHandler = () => this._handleTransitionEnd();
+    this.addEventListener('transitionend', this._boundTransitionEndHandler);
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    if (this._boundTransitionEndHandler) {
+      this.removeEventListener(
+        'transitionend',
+        this._boundTransitionEndHandler
+      );
+    }
+  }
+
+  show(text: string, actionText?: string, actionCallback?: () => void) {
+    this.text = text;
+    this.actionText = actionText;
+    this._hideActionButton = !actionText;
+    this._actionCallback = actionCallback;
+    getRootElement().appendChild(this);
+    this.shown = true;
+  }
+
+  hide() {
+    this.shown = 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;
+  }
+
+  _handleTransitionEnd() {
+    if (this.shown) {
+      return;
+    }
+
+    getRootElement().removeChild(this);
+  }
+
+  _handleActionTap(e: MouseEvent) {
+    e.preventDefault();
+    if (this._actionCallback) {
+      this._actionCallback();
+    }
+  }
+}
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
deleted file mode 100644
index e9f386d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
new file mode 100644
index 0000000..d2aed40
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 557ec28..0000000
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ /dev/null
@@ -1,59 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-alert</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 './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.shadowRoot
-        .querySelector('.action'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
new file mode 100644
index 0000000..11ec496
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-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', () => {
+    const spy = sinon.spy();
+    element.show();
+    element._actionCallback = spy;
+    assert.isFalse(spy.called);
+    MockInteractions.tap(element.shadowRoot.querySelector('.action'));
+    assert.isTrue(spy.called);
+  });
+});
+
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
deleted file mode 100644
index 46e8829..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
+++ /dev/null
@@ -1,210 +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.
- */
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrAutocompleteDropdown extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-  IronFitBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-autocomplete-dropdown'; }
-  /**
-   * Fired when the dropdown is closed.
-   *
-   * @event dropdown-closed
-   */
-
-  /**
-   * Fired when item is selected.
-   *
-   * @event item-selected
-   */
-
-  static get properties() {
-    return {
-      index: Number,
-      isHidden: {
-        type: Boolean,
-        value: true,
-        reflectToAttribute: true,
-      },
-      verticalOffset: {
-        type: Number,
-        value: null,
-      },
-      horizontalOffset: {
-        type: Number,
-        value: null,
-      },
-      suggestions: {
-        type: Array,
-        value: () => [],
-        observer: '_resetCursorStops',
-      },
-      _suggestionEls: Array,
-    };
-  }
-
-  get keyBindings() {
-    return {
-      up: '_handleUp',
-      down: '_handleDown',
-      enter: '_handleEnter',
-      esc: '_handleEscape',
-      tab: '_handleTab',
-    };
-  }
-
-  close() {
-    this.isHidden = true;
-  }
-
-  open() {
-    this.isHidden = false;
-    this._resetCursorStops();
-    // Refit should run after we call Polymer.flush inside _resetCursorStops
-    this.refit();
-  }
-
-  getCurrentText() {
-    return this.getCursorTarget().dataset.value;
-  }
-
-  _handleUp(e) {
-    if (!this.isHidden) {
-      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) {
-    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.dispatchEvent(new CustomEvent('item-selected', {
-      detail: {
-        trigger: 'enter',
-        selected: this.$.cursor.target,
-      },
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _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.dispatchEvent(new CustomEvent('item-selected', {
-      detail: {
-        trigger: 'click',
-        selected,
-      },
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _fireClose() {
-    this.dispatchEvent(new CustomEvent('dropdown-closed', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  getCursorTarget() {
-    return this.$.cursor.target;
-  }
-
-  _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);
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
new file mode 100644
index 0000000..451bdfa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -0,0 +1,241 @@
+/**
+ * @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-dropdown/iron-dropdown';
+import '../gr-cursor-manager/gr-cursor-manager';
+import '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-autocomplete-dropdown_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin';
+import {customElement, property, observe} from '@polymer/decorators';
+import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
+
+// TODO(TS): Update once GrCursorManager is upated
+export interface GrAutocompleteDropdown {
+  $: {
+    cursor: any;
+    suggestions: Element;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-autocomplete-dropdown': GrAutocompleteDropdown;
+  }
+}
+
+interface Item {
+  dataValue?: string;
+  name?: string;
+  text?: string;
+  label?: string;
+  value?: string;
+}
+
+/**
+ * @extends PolymerElement
+ */
+@customElement('gr-autocomplete-dropdown')
+export class GrAutocompleteDropdown extends IronFitMixin(
+  KeyboardShortcutMixin(
+    GestureEventListeners(LegacyElementMixin(PolymerElement))
+  ),
+  IronFitBehavior as IronFitBehavior
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the dropdown is closed.
+   *
+   * @event dropdown-closed
+   */
+
+  /**
+   * Fired when item is selected.
+   *
+   * @event item-selected
+   */
+
+  @property({type: Number})
+  index: number | null = null;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  isHidden = true;
+
+  @property({type: Number})
+  verticalOffset: number | null = null;
+
+  @property({type: Number})
+  horizontalOffset: number | null = null;
+
+  @property({type: Array})
+  suggestions: Item[] = [];
+
+  @property({type: Array})
+  _suggestionEls: Element[] = [];
+
+  get keyBindings() {
+    return {
+      up: '_handleUp',
+      down: '_handleDown',
+      enter: '_handleEnter',
+      esc: '_handleEscape',
+      tab: '_handleTab',
+    };
+  }
+
+  close() {
+    this.isHidden = true;
+  }
+
+  open() {
+    this.isHidden = false;
+    this._resetCursorStops();
+    // Refit should run after we call Polymer.flush inside _resetCursorStops
+    this.refit();
+  }
+
+  getCurrentText() {
+    return this.getCursorTarget().dataset['value'];
+  }
+
+  _handleUp(e: Event) {
+    if (!this.isHidden) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.cursorUp();
+    }
+  }
+
+  _handleDown(e: Event) {
+    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: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('item-selected', {
+        detail: {
+          trigger: 'tab',
+          selected: this.$.cursor.target,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleEnter(e: Event) {
+    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();
+  }
+
+  _handleClickItem(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    let selected = e.target! as Element;
+    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.dispatchEvent(
+      new CustomEvent('dropdown-closed', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  getCursorTarget() {
+    return this.$.cursor.target;
+  }
+
+  @observe('suggestions')
+  _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);
+  }
+
+  _computeLabelClass(item: Item) {
+    return item.label ? '' : 'hide';
+  }
+}
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
deleted file mode 100644
index b31af73..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
new file mode 100644
index 0000000..d3d2481
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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-mode="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
deleted file mode 100644
index d836155..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
+++ /dev/null
@@ -1,154 +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">
-<meta charset="utf-8">
-<title>gr-autocomplete-dropdown</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-autocomplete-dropdown></gr-autocomplete-dropdown>
-  </template>
-</test-fixture>
-
-<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();
-  });
-
-  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('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', () => {
-    const resetStopsSpy = sandbox.spy(element, '_resetCursorStops');
-    element.suggestions = [];
-    assert.isTrue(resetStopsSpy.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
new file mode 100644
index 0000000..ad06649
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
@@ -0,0 +1,136 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-autocomplete-dropdown.js';
+
+const basicFixture = fixtureFromElement('gr-autocomplete-dropdown');
+
+suite('gr-autocomplete-dropdown', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    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}];
+    flush();
+  });
+
+  teardown(() => {
+    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('escape key', () => {
+    const closeSpy = sinon.spy(element, 'close');
+    MockInteractions.pressAndReleaseKeyOn(element, 27);
+    flush();
+    assert.isTrue(closeSpy.called);
+  });
+
+  test('tab key', () => {
+    const handleTabSpy = sinon.spy(element, '_handleTab');
+    const itemSelectedStub = sinon.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 = sinon.spy(element, '_handleEnter');
+    const itemSelectedStub = sinon.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 = sinon.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 = sinon.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 = sinon.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+
+    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
+    flush();
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'click',
+      selected: element.$.suggestions.querySelectorAll('li')[1],
+    });
+  });
+
+  test('tapping child still selects item', () => {
+    const itemSelectedStub = sinon.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+
+    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
+        .lastElementChild);
+    flush();
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'click',
+      selected: element.$.suggestions.querySelectorAll('li')[0],
+    });
+  });
+
+  test('updated suggestions resets cursor stops', () => {
+    const resetStopsSpy = sinon.spy(element, '_resetCursorStops');
+    element.suggestions = [];
+    assert.isTrue(resetStopsSpy.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
deleted file mode 100644
index 7f9ed72..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ /dev/null
@@ -1,480 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
-const DEBOUNCE_WAIT_MS = 200;
-
-/**
- * @extends Polymer.Element
- */
-class GrAutocomplete extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-autocomplete'; }
-  /**
-   * Fired when a value is chosen.
-   *
-   * @event commit
-   */
-
-  /**
-   * Fired when the user cancels.
-   *
-   * @event cancel
-   */
-
-  /**
-   * 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
-       * accept the input as a string parameter and return a promise. The
-       * promise yields an array of suggestion objects with "name", "label",
-       * "value" properties. The "name" property will be displayed in the
-       * suggestion entry. The "label" property will, when specified, appear
-       * next to the "name" as label text. The "value" property will be emitted
-       * if that suggestion is selected.
-       *
-       * @type {function(string): Promise<?>}
-       */
-      query: {
-        type: Function,
-        value() {
-          return function() {
-            return Promise.resolve([]);
-          };
-        },
-      },
-
-      /**
-       * The number of characters that must be typed before suggestions are
-       * made. If threshold is zero, default suggestions are enabled.
-       */
-      threshold: {
-        type: Number,
-        value: 1,
-      },
-
-      allowNonSuggestedValues: Boolean,
-      borderless: Boolean,
-      disabled: Boolean,
-      showSearchIcon: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * Vertical offset needed for an element with 20px line-height, 4px
-       * padding and 1px border (30px height total). Plus 1px spacing between
-       * input and dropdown. Inputs with different line-height or padding will
-       * need to tweak vertical offset.
-       */
-      verticalOffset: {
-        type: Number,
-        value: 31,
-      },
-
-      text: {
-        type: String,
-        value: '',
-        notify: true,
-      },
-
-      placeholder: String,
-
-      clearOnCommit: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * When true, tab key autocompletes but does not fire the commit event.
-       * When false, tab key not caught, and focus is removed from the element.
-       * See Issue 4556, Issue 6645.
-       */
-      tabComplete: {
-        type: Boolean,
-        value: false,
-      },
-
-      value: {
-        type: String,
-        notify: true,
-      },
-
-      /**
-       * Multi mode appends autocompleted entries to the value.
-       * If false, autocompleted entries replace value.
-       */
-      multi: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * When true and uncommitted text is left in the autocomplete input after
-       * blurring, the text will appear red.
-       */
-      warnUncommitted: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * When true, querying for suggestions is not debounced w/r/t keypresses
-       */
-      noDebounce: {
-        type: Boolean,
-        value: false,
-      },
-
-      /** @type {?} */
-      _suggestions: {
-        type: Array,
-        value() { return []; },
-      },
-
-      _suggestionEls: {
-        type: Array,
-        value() { return []; },
-      },
-
-      _index: Number,
-      _disableSuggestions: {
-        type: Boolean,
-        value: false,
-      },
-      _focused: {
-        type: Boolean,
-        value: false,
-      },
-
-      /** The DOM element of the selected suggestion. */
-      _selected: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_maybeOpenDropdown(_suggestions, _focused)',
-      '_updateSuggestions(text, threshold, noDebounce)',
-    ];
-  }
-
-  get _nativeInput() {
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    return this.$.input.$.nativeInput || this.$.input.inputElement;
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.listen(document.body, 'click', '_handleBodyClick');
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(document.body, 'click', '_handleBodyClick');
-    this.cancelDebouncer('update-suggestions');
-  }
-
-  get focusStart() {
-    return this.$.input;
-  }
-
-  focus() {
-    this._nativeInput.focus();
-  }
-
-  selectAll() {
-    const nativeInputElement = this._nativeInput;
-    if (!this.$.input.value) { return; }
-    nativeInputElement.setSelectionRange(0, this.$.input.value.length);
-  }
-
-  clear() {
-    this.text = '';
-  }
-
-  _handleItemSelect(e) {
-    // Let _handleKeydown deal with keyboard interaction.
-    if (e.detail.trigger !== 'click') { return; }
-    this._selected = e.detail.selected;
-    this._commit();
-  }
-
-  get _inputElement() {
-    // Polymer2: this.$ can be undefined when this is first evaluated.
-    return this.$ && this.$.input;
-  }
-
-  /**
-   * Set the text of the input without triggering the suggestion dropdown.
-   *
-   * @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();
-  }
-
-  _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;
-    }
-
-    // Reset _suggestions for every update
-    // This will also prevent from carrying over suggestions:
-    // @see Issue 12039
-    this._suggestions = [];
-
-    // TODO(taoalpha): Also skip if text has not changed
-
-    if (this._disableSuggestions) { return; }
-    if (text.length < threshold) {
-      this.value = '';
-      return;
-    }
-
-    if (!this._focused) {
-      return;
-    }
-
-    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;
-        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;
-  }
-
-  _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._suggestions = [];
-    if (!opt_silent) {
-      this.dispatchEvent(new CustomEvent('commit', {
-        detail: {value},
-        composed: true, bubbles: true,
-      }));
-    }
-
-    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.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
new file mode 100644
index 0000000..45f30f5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -0,0 +1,512 @@
+/**
+ * @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 '@polymer/paper-input/paper-input';
+import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import '../gr-cursor-manager/gr-cursor-manager';
+import '../gr-icons/gr-icons';
+import '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-autocomplete_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {property, customElement, observe} from '@polymer/decorators';
+import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
+import {PaperInputElementExt} from '../../../types/types';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
+const DEBOUNCE_WAIT_MS = 200;
+
+export interface GrAutocomplete {
+  $: {
+    input: PaperInputElementExt;
+    suggestions: GrAutocompleteDropdown;
+    cursor: GrCursorManager;
+  };
+}
+
+export type AutocompleteQuery = (
+  text: string
+) => Promise<AutocompleteSuggestion[]>;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-autocomplete': GrAutocomplete;
+  }
+}
+
+export interface AutocompleteSuggestion {
+  name?: string;
+  label?: string;
+  // TODO(TS): this value can be string or arbitrary object (in gr-create-repo-dialog)
+  // probably should limit it to string only as it seems not used
+  value?: any;
+  text?: string;
+}
+
+export interface AutocompleteCommitEventDetail {
+  value: string;
+}
+
+export type AutocompleteCommitEvent = CustomEvent<
+  AutocompleteCommitEventDetail
+>;
+
+@customElement('gr-autocomplete')
+export class GrAutocomplete extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+  /**
+   * Fired when a value is chosen.
+   *
+   * @event commit
+   */
+
+  /**
+   * Fired when the user cancels.
+   *
+   * @event cancel
+   */
+
+  /**
+   * Fired on keydown to allow for custom hooks into autocomplete textbox
+   * behavior.
+   *
+   * @event input-keydown
+   */
+
+  /**
+   * Query for requesting autocomplete suggestions. The function should
+   * accept the input as a string parameter and return a promise. The
+   * promise yields an array of suggestion objects with "name", "label",
+   * "value" properties. The "name" property will be displayed in the
+   * suggestion entry. The "label" property will, when specified, appear
+   * next to the "name" as label text. The "value" property will be emitted
+   * if that suggestion is selected.
+   *
+   */
+  @property({type: Object})
+  query: AutocompleteQuery = () => Promise.resolve([]);
+
+  /**
+   * The number of characters that must be typed before suggestions are
+   * made. If threshold is zero, default suggestions are enabled.
+   */
+  @property({type: Number})
+  threshold = 1;
+
+  @property({type: Boolean})
+  allowNonSuggestedValues = false;
+
+  @property({type: Boolean})
+  borderless = false;
+
+  @property({type: Boolean})
+  disabled = false;
+
+  @property({type: Boolean})
+  showSearchIcon = false;
+
+  /**
+   * Vertical offset needed for an element with 20px line-height, 4px
+   * padding and 1px border (30px height total). Plus 1px spacing between
+   * input and dropdown. Inputs with different line-height or padding will
+   * need to tweak vertical offset.
+   */
+  @property({type: Number})
+  verticalOffset = 31;
+
+  @property({type: String, notify: true})
+  text = '';
+
+  @property({type: String})
+  placeholder = '';
+
+  @property({type: Boolean})
+  clearOnCommit = false;
+
+  /**
+   * When true, tab key autocompletes but does not fire the commit event.
+   * When false, tab key not caught, and focus is removed from the element.
+   * See Issue 4556, Issue 6645.
+   */
+  @property({type: Boolean})
+  tabComplete = false;
+
+  @property({type: String, notify: true})
+  value = '';
+
+  /**
+   * Multi mode appends autocompleted entries to the value.
+   * If false, autocompleted entries replace value.
+   */
+  @property({type: Boolean})
+  multi = false;
+
+  /**
+   * When true and uncommitted text is left in the autocomplete input after
+   * blurring, the text will appear red.
+   */
+  @property({type: Boolean})
+  warnUncommitted = false;
+
+  /**
+   * When true, querying for suggestions is not debounced w/r/t keypresses
+   */
+  @property({type: Boolean})
+  noDebounce = false;
+
+  @property({type: Array})
+  _suggestions: AutocompleteSuggestion[] = [];
+
+  @property({type: Array})
+  _suggestionEls = [];
+
+  @property({type: Number})
+  _index?: number;
+
+  @property({type: Boolean})
+  _disableSuggestions = false;
+
+  @property({type: Boolean})
+  _focused = false;
+
+  /**
+   * Invisible label for input element. This label is exposed to
+   * screen readers by paper-input
+   */
+  @property({type: String})
+  label = '';
+
+  /** The DOM element of the selected suggestion. */
+  @property({type: Object})
+  _selected: HTMLElement | null = null;
+
+  get _nativeInput() {
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return (this.$.input.$.nativeInput ||
+      this.$.input.inputElement) as HTMLInputElement;
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(document.body, 'click', '_handleBodyClick');
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(document.body, 'click', '_handleBodyClick');
+    this.cancelDebouncer('update-suggestions');
+  }
+
+  get focusStart() {
+    return this.$.input;
+  }
+
+  focus() {
+    this._nativeInput.focus();
+  }
+
+  selectAll() {
+    const nativeInputElement = this._nativeInput;
+    if (!this.$.input.value) {
+      return;
+    }
+    nativeInputElement.setSelectionRange(0, this.$.input.value.length);
+  }
+
+  clear() {
+    this.text = '';
+  }
+
+  _handleItemSelect(e: CustomEvent) {
+    // Let _handleKeydown deal with keyboard interaction.
+    if (e.detail.trigger !== 'click') {
+      return;
+    }
+    this._selected = e.detail.selected;
+    this._commit();
+  }
+
+  get _inputElement() {
+    // Polymer2: this.$ can be undefined when this is first evaluated.
+    return this.$ && this.$.input;
+  }
+
+  /**
+   * Set the text of the input without triggering the suggestion dropdown.
+   *
+   * @param text The new text for the input.
+   */
+  setText(text: string) {
+    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();
+  }
+
+  _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();
+  }
+
+  @observe('text', 'threshold', 'noDebounce')
+  _updateSuggestions(text?: string, threshold?: number, noDebounce?: boolean) {
+    if (
+      text === undefined ||
+      threshold === undefined ||
+      noDebounce === undefined
+    )
+      return;
+
+    // Reset _suggestions for every update
+    // This will also prevent from carrying over suggestions:
+    // @see Issue 12039
+    this._suggestions = [];
+
+    // TODO(taoalpha): Also skip if text has not changed
+
+    if (this._disableSuggestions) {
+      return;
+    }
+    if (text.length < threshold) {
+      this.value = '';
+      return;
+    }
+
+    if (!this._focused) {
+      return;
+    }
+
+    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;
+        flush();
+        if (this._index === -1) {
+          this.value = '';
+        }
+      });
+    };
+
+    if (noDebounce) {
+      update();
+    } else {
+      this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
+    }
+  }
+
+  @observe('_suggestions', '_focused')
+  _maybeOpenDropdown(suggestions: AutocompleteSuggestion[], focused: boolean) {
+    if (suggestions.length > 0 && focused) {
+      return this.$.suggestions.open();
+    }
+    return this.$.suggestions.close();
+  }
+
+  _computeClass(borderless: boolean) {
+    return borderless ? 'borderless' : '';
+  }
+
+  /**
+   * _handleKeydown used for key handling in the this.$.input AND all child
+   * autocomplete options.
+   */
+  _handleKeydown(e: CustomKeyboardEvent) {
+    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,
+        })
+      );
+    }
+  }
+
+  _handleInputCommit(_tabComplete?: boolean) {
+    // Nothing to do if the dropdown is not open.
+    if (!this.allowNonSuggestedValues && this.$.suggestions.isHidden) {
+      return;
+    }
+
+    this._selected = this.$.suggestions.getCursorTarget();
+    this._commit(_tabComplete);
+  }
+
+  _updateValue(
+    suggestion: HTMLElement | null,
+    suggestions: AutocompleteSuggestion[]
+  ) {
+    if (!suggestion) {
+      return;
+    }
+    const index = Number(suggestion.dataset['index']!);
+    if (isNaN(index)) return;
+    const completed = suggestions[index].value;
+    if (completed === undefined || completed === null) return;
+    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);
+      if (tokens?.length) {
+        tokens[tokens.length - 1] = completed;
+        this.value = tokens.join(' ');
+      }
+    } else {
+      this.value = completed;
+    }
+  }
+
+  _handleBodyClick(e: Event) {
+    const eventPath = e.composedPath();
+    if (!eventPath) return;
+    for (let i = 0; i < eventPath.length; i++) {
+      if (eventPath[i] === this) {
+        return;
+      }
+    }
+    this._focused = false;
+  }
+
+  /**
+   * Commits the suggestion, optionally firing the commit event.
+   *
+   * @param silent Allows for silent committing of an
+   * autocomplete suggestion in order to handle cases like tab-to-complete
+   * without firing the commit event.
+   */
+  _commit(silent?: boolean) {
+    // 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) {
+        const dataSet = this._selected.dataset;
+        // index property cannot be null for the data-set
+        if (dataSet) {
+          const index = Number(dataSet['index']!);
+          if (isNaN(index)) return;
+          this.setText(this._suggestions[index].name || '');
+        }
+      } else {
+        this.clear();
+      }
+    }
+
+    this._suggestions = [];
+    if (!silent) {
+      this.dispatchEvent(
+        new CustomEvent('commit', {
+          detail: {value} as AutocompleteCommitEventDetail,
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
+  }
+
+  _computeShowSearchIconClass(showSearchIcon: boolean) {
+    return showSearchIcon ? 'showSearchIcon' : '';
+  }
+}
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
deleted file mode 100644
index eae8741..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
new file mode 100644
index 0000000..62775aa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
@@ -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';
+
+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;
+      }
+      /* Hide label for input. The label is still visible for
+      screen readers. Workaround found at:
+      https://github.com/PolymerElements/paper-input/issues/478 */
+      --paper-input-container-label: {
+        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"
+    label="[[label]]"
+  >
+    <!-- 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>
+
+    <!-- suffix as attribute is required to for polymer 1 -->
+    <div slot="suffix" suffix="">
+      <slot name="suffix"></slot>
+    </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
deleted file mode 100644
index 6dd5a97..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ /dev/null
@@ -1,612 +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">
-<meta charset="utf-8">
-<title>gr-reviewer-list</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-autocomplete no-debounce></gr-autocomplete>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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');
-      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);
-    });
-
-    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');
-      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('tap on suggestion commits, does not call focus', () => {
-      focusSpy = sandbox.spy(element, 'focus');
-      element._focused = true;
-      element._suggestions = [{name: 'first suggestion'}];
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-      MockInteractions.tap(element.$.suggestions.shadowRoot
-          .querySelector('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');
-    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-autocomplete/gr-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
new file mode 100644
index 0000000..329265e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
@@ -0,0 +1,580 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-autocomplete.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromTemplate(
+    html`<gr-autocomplete no-debounce></gr-autocomplete>`);
+
+suite('gr-autocomplete tests', () => {
+  let element;
+
+  const focusOnInput = element => {
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+        'enter');
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('renders', () => {
+    let promise;
+    const queryStub = sinon.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 =
+          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', async () => {
+    await flush();
+    const nativeInput = element._nativeInput;
+    const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
+
+    element.selectAll();
+    assert.isFalse(selectionStub.called);
+
+    element.$.input.value = 'test';
+    element.selectAll();
+    assert.isTrue(selectionStub.called);
+  });
+
+  test('esc key behavior', () => {
+    let promise;
+    const queryStub = sinon.spy(() => promise = Promise.resolve([
+      {name: 'blah', value: 123},
+    ]));
+    element.query = queryStub;
+
+    assert.isTrue(element.$.suggestions.isHidden);
+
+    element._focused = true;
+    element.text = 'blah';
+
+    return promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      const cancelHandler = sinon.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);
+    });
+  });
+
+  test('emits commit and handles cursor movement', () => {
+    let promise;
+    const queryStub = sinon.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';
+
+    return promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      const commitHandler = sinon.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);
+    });
+  });
+
+  test('clear-on-commit behavior (off)', () => {
+    let promise;
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah';
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'suggestion');
+    });
+  });
+
+  test('clear-on-commit behavior (on)', () => {
+    let promise;
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah';
+    element.clearOnCommit = true;
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, '');
+    });
+  });
+
+  test('threshold guards the query', () => {
+    const queryStub = sinon.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 = sinon.spy(() => Promise.resolve([]));
+    let callback;
+    const debounceStub = sinon.stub(element, 'debounce').callsFake(
+        (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', () => {
+    let promise;
+    const queryStub = sinon.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);
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      assert.equal(queryStub.notCalled, false);
+    });
+  });
+
+  test('when not focused', () => {
+    let promise;
+    const queryStub = sinon.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    element.suggestOnlyWhenFocus = true;
+    element.text = 'bla';
+    assert.equal(element._focused, false);
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 0);
+    });
+  });
+
+  test('suggestions should not carry over', () => {
+    let promise;
+    const queryStub = sinon.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'bla';
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      element._updateSuggestions('', 0, false);
+      assert.equal(element._suggestions.length, 0);
+    });
+  });
+
+  test('multi completes only the last part of the query', () => {
+    let promise;
+    const queryStub = sinon.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah blah';
+    element.multi = true;
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'blah 0');
+    });
+  });
+
+  test('tabComplete flag functions', () => {
+    // commitHandler checks for the commit event, whereas commitSpy checks for
+    // the _commit function of the element.
+    const commitHandler = sinon.spy();
+    element.addEventListener('commit', commitHandler);
+    const commitSpy = sinon.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', () => {
+    flush();
+    assert.isFalse(element._focused);
+    const input = element.shadowRoot
+        .querySelector('paper-input').inputElement;
+    MockInteractions.focus(input);
+    assert.isTrue(element._focused);
+  });
+
+  test('search icon shows with showSearchIcon property', () => {
+    flush();
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('iron-icon')).display,
+    'none');
+    element.showSearchIcon = true;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('iron-icon')).display,
+    'none');
+  });
+
+  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 = sinon.stub(element.$.suggestions, 'open');
+    const closedStub = sinon.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 = sinon.stub(element, '_commit');
+    element.$.suggestions.isHidden = true;
+    element._handleInputCommit();
+    assert.isFalse(commitStub.called);
+  });
+
+  test('_handleInputCommit with autocomplete hidden with' +
+        'allowNonSuggestedValues', () => {
+    const commitStub = sinon.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 = sinon.stub(element, '_commit');
+    element.$.suggestions.isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test('_handleInputCommit with autocomplete open calls commit' +
+        'with allowNonSuggestedValues', () => {
+    const commitStub = sinon.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 = sinon.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 = sinon.spy(element, '_commit');
+    });
+
+    test('enter does not call focus', () => {
+      element._suggestions = ['sugar bombs'];
+      focusSpy = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = true', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      const commitHandler = sinon.stub();
+      element.addEventListener('commit', commitHandler);
+      element.tabComplete = true;
+      element._suggestions = ['tunnel snakes drool'];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      flush();
+
+      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 = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      flush();
+
+      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 = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.suggestions.shadowRoot
+              .querySelector('li:first-child'), 9, null, 'tab');
+      flush();
+      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 = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.suggestions.shadowRoot
+              .querySelector('li:first-child'), 9, null, 'tab');
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element._focused);
+    });
+
+    test('tap on suggestion commits, does not call focus', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      element._focused = true;
+      element._suggestions = [{name: 'first suggestion'}];
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+      MockInteractions.tap(element.$.suggestions.shadowRoot
+          .querySelector('li:first-child'));
+      flush();
+
+      assert.isFalse(focusSpy.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element.$.suggestions.isHidden);
+    });
+  });
+
+  test('input-keydown event fired', () => {
+    const listener = sinon.spy();
+    element.addEventListener('input-keydown', listener);
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    flush();
+    assert.isTrue(listener.called);
+  });
+
+  test('enter with modifier does not complete', () => {
+    const handleSpy = sinon.spy(element, '_handleKeydown');
+    const commitStub = sinon.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'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
deleted file mode 100644
index 75181b1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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',
-      },
-      imageSize: {
-        type: Number,
-        value: 16,
-      },
-      _hasAvatars: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    Promise.all([
-      this._getConfig(),
-      pluginLoader.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;
-    }
-    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;
-      }
-    }
-    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.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
new file mode 100644
index 0000000..45bac9f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../gr-js-api-interface/gr-js-api-interface';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-avatar_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrAvatar {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-avatar')
+export class GrAvatar extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object, observer: '_accountChanged'})
+  account?: AccountInfo;
+
+  @property({type: Number})
+  imageSize = 16;
+
+  @property({type: Boolean})
+  _hasAvatars = false;
+
+  /** @override */
+  attached() {
+    super.attached();
+    Promise.all([
+      this._getConfig(),
+      getPluginLoader().awaitPluginsLoaded(),
+    ]).then(([cfg]) => {
+      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+
+      this._updateAvatarURL();
+    });
+  }
+
+  _getConfig() {
+    return this.$.restAPI.getConfig();
+  }
+
+  _accountChanged() {
+    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: AccountInfo) {
+    return (
+      account._account_id || account.email || account.username || account.name
+    );
+  }
+
+  _buildAvatarURL(account: AccountInfo) {
+    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;
+      }
+    }
+    const accountID = this._getAccounts(account);
+    if (!accountID) {
+      return '';
+    }
+    return (
+      `${getBaseUrl()}/accounts/` +
+      encodeURIComponent(`${this._getAccounts(account)}`) +
+      `/avatar?s=${this.imageSize}`
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-avatar': 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
deleted file mode 100644
index cc8a42f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
new file mode 100644
index 0000000..0d8e78f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
@@ -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';
+
+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
deleted file mode 100644
index dddc3d8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ /dev/null
@@ -1,209 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-avatar</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-avatar></gr-avatar>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-avatar.js';
-import {pluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
-
-suite('gr-avatar tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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'));
-
-      assert.isTrue(
-          element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
-    });
-  });
-
-  suite('plugin has avatars', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      stub('gr-avatar', {
-        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
-      });
-
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('dom for non available account', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
-
-      // Emulate plugins loaded.
-      pluginLoader.loadPlugins([]);
-
-      return Promise.all([
-        element.$.restAPI.getConfig(),
-        pluginLoader.awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
-
-        assert.strictEqual(element.style.backgroundImage, '');
-      });
-    });
-  });
-
-  suite('config not set', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      stub('gr-avatar', {
-        _getConfig: () => Promise.resolve({}),
-      });
-
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('avatar hidden when account set', () => {
-      flush(() => {
-        assert.isFalse(element.hasAttribute('hidden'));
-
-        element.imageSize = 64;
-        element.account = {
-          _account_id: 123,
-        };
-        // Emulate plugins loaded.
-        pluginLoader.loadPlugins([]);
-
-        return Promise.all([
-          element.$.restAPI.getConfig(),
-          pluginLoader.awaitPluginsLoaded(),
-        ]).then(() => {
-          assert.isTrue(element.hasAttribute('hidden'));
-        });
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
new file mode 100644
index 0000000..261d59c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-avatar.js';
+import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
+
+const basicFixture = fixtureFromElement('gr-avatar');
+
+suite('gr-avatar tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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), '');
+  });
+
+  suite('config set', () => {
+    setup(() => {
+      stub('gr-avatar', {
+        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
+      });
+      element = basicFixture.instantiate();
+    });
+
+    test('dom for existing account', () => {
+      assert.isFalse(element.hasAttribute('hidden'));
+
+      element.imageSize = 64;
+      element.account = {
+        _account_id: 123,
+      };
+
+      assert.strictEqual(element.style.backgroundImage, '');
+
+      // Emulate plugins loaded.
+      getPluginLoader().loadPlugins([]);
+
+      return Promise.all([
+        element.$.restAPI.getConfig(),
+        getPluginLoader().awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isFalse(element.hasAttribute('hidden'));
+
+        assert.isTrue(
+            element.style.backgroundImage.includes(
+                '/accounts/123/avatar?s=64'));
+      });
+    });
+  });
+
+  suite('plugin has avatars', () => {
+    let element;
+
+    setup(() => {
+      stub('gr-avatar', {
+        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
+      });
+
+      element = basicFixture.instantiate();
+    });
+
+    test('dom for non available account', () => {
+      assert.isFalse(element.hasAttribute('hidden'));
+
+      // Emulate plugins loaded.
+      getPluginLoader().loadPlugins([]);
+
+      return Promise.all([
+        element.$.restAPI.getConfig(),
+        getPluginLoader().awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isTrue(element.hasAttribute('hidden'));
+
+        assert.strictEqual(element.style.backgroundImage, '');
+      });
+    });
+  });
+
+  suite('config not set', () => {
+    let element;
+
+    setup(() => {
+      stub('gr-avatar', {
+        _getConfig: () => Promise.resolve({}),
+      });
+
+      element = basicFixture.instantiate();
+    });
+
+    test('avatar hidden when account set', async () => {
+      await flush();
+      assert.isTrue(element.hasAttribute('hidden'));
+
+      element.imageSize = 64;
+      element.account = {
+        _account_id: 123,
+      };
+      // Emulate plugins loaded.
+      getPluginLoader().loadPlugins([]);
+
+      return Promise.all([
+        element.$.restAPI.getConfig(),
+        getPluginLoader().awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isTrue(element.hasAttribute('hidden'));
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
deleted file mode 100644
index 3169c56..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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,
-        reflectToAttribute: true,
-      },
-      link: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        observer: '_disabledChanged',
-        reflectToAttribute: true,
-      },
-      noUppercase: {
-        type: Boolean,
-        value: false,
-      },
-      loading: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-
-      _disabled: {
-        type: Boolean,
-        computed: '_computeDisabled(disabled, loading)',
-      },
-
-      _initialTabindex: {
-        type: String,
-        value: '0',
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this._initialTabindex = this.getAttribute('tabindex') || '0';
-    this.addEventListener('click', e => this._handleAction(e));
-    this.addEventListener('keydown',
-        e => this._handleKeydown(e));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'button');
-    this._ensureAttribute('tabindex', '0');
-  }
-
-  _handleAction(e) {
-    if (this._disabled) {
-      e.preventDefault();
-      e.stopPropagation();
-      e.stopImmediatePropagation();
-      return;
-    }
-
-    this.$.reporting.reportInteraction('button-click',
-        {path: util.getEventPath(e)});
-  }
-
-  _disabledChanged(disabled) {
-    this.setAttribute('tabindex', disabled ? '-1' : this._initialTabindex);
-    this.updateStyles();
-  }
-
-  _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();
-    }
-  }
-}
-
-customElements.define(GrButton.is, GrButton);
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
new file mode 100644
index 0000000..7a6ce2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -0,0 +1,138 @@
+/**
+ * @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 '@polymer/paper-button/paper-button';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {htmlTemplate} from './gr-button_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {PolymerEvent, getEventPath} from '../../../utils/dom-util';
+import {appContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-button': GrButton;
+  }
+}
+
+@customElement('gr-button')
+export class GrButton extends LegacyElementMixin(
+  KeyboardShortcutMixin(TooltipMixin(GestureEventListeners(PolymerElement)))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, reflectToAttribute: true})
+  downArrow = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  link = false;
+
+  @property({type: Boolean})
+  noUppercase = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loading = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled: boolean | null = null;
+
+  @property({type: String})
+  tooltip = '';
+
+  // Note: don't assign a value to this, since constructor is called
+  // after created, the initial value maybe overriden by this
+  @property({type: String})
+  _initialTabindex?: string;
+
+  @computed('disabled', 'loading')
+  get _disabled() {
+    return this.disabled || this.loading;
+  }
+
+  @property({
+    computed: 'computeAriaDisabled(disabled, loading)',
+    reflectToAttribute: true,
+    type: Boolean,
+  })
+  ariaDisabled!: boolean;
+
+  computeAriaDisabled() {
+    return this._disabled;
+  }
+
+  private readonly reporting: ReportingService = appContext.reportingService;
+
+  /** @override */
+  created() {
+    super.created();
+    this._initialTabindex = this.getAttribute('tabindex') || '0';
+    // TODO(TS): try avoid using unknown
+    this.addEventListener('click', e =>
+      this._handleAction((e as unknown) as PolymerEvent)
+    );
+    this.addEventListener('keydown', e =>
+      this._handleKeydown((e as unknown) as CustomKeyboardEvent)
+    );
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'button');
+    this._ensureAttribute('tabindex', '0');
+  }
+
+  _handleAction(e: PolymerEvent) {
+    if (this._disabled) {
+      e.preventDefault();
+      e.stopPropagation();
+      e.stopImmediatePropagation();
+      return;
+    }
+
+    this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
+  }
+
+  @observe('disabled')
+  _disabledChanged(disabled: boolean) {
+    this.setAttribute(
+      'tabindex',
+      disabled ? '-1' : this._initialTabindex || '0'
+    );
+    this.updateStyles();
+  }
+
+  _handleKeydown(e: CustomKeyboardEvent) {
+    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();
+    }
+  }
+}
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
deleted file mode 100644
index db3b880..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
new file mode 100644
index 0000000..b272951
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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 replacement 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: var(--spacing-s);
+    }
+    :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>
+`;
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
deleted file mode 100644
index ae627d1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ /dev/null
@@ -1,223 +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">
-<meta charset="utf-8">
-<title>gr-button</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-button></gr-button>
-  </template>
-</test-fixture>
-
-<test-fixture id="nested">
-  <template>
-    <div id="test">
-      <gr-button class="testBtn"></gr-button>
-    </div>
-  </template>
-</test-fixture>
-
-<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.disabled = true;
-    });
-
-    for (const eventName of ['tap', 'click']) {
-      test('stops ' + eventName + ' event', () => {
-        const spy = addSpyOn(eventName);
-        MockInteractions.tap(element);
-        assert.isFalse(spy.called);
-      });
-    }
-
-    // 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);
-      });
-    }
-  });
-
-  suite('reporting', () => {
-    const reportStub = sinon.stub();
-    setup(() => {
-      stub('gr-reporting', {
-        reportInteraction: (...args) => {
-          reportStub(...args);
-        },
-      });
-      reportStub.reset();
-    });
-
-    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',
-      });
-    });
-
-    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-button/gr-button_test.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
new file mode 100644
index 0000000..242cb28
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
@@ -0,0 +1,197 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-button.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+import {appContext} from '../../../services/app-context.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromElement('gr-button');
+
+const nestedFixture = fixtureFromTemplate(html`
+<div id="test">
+  <gr-button class="testBtn"></gr-button>
+</div>
+`);
+
+const tabindexFixture = fixtureFromTemplate(html`
+  <gr-button tabindex="3"></gr-button>
+`);
+
+suite('gr-button tests', () => {
+  let element;
+
+  const addSpyOn = function(eventName) {
+    const spy = sinon.spy();
+    if (eventName == 'tap') {
+      addListener(element, eventName, spy);
+    } else {
+      element.addEventListener(eventName, spy);
+    }
+    return spy;
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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', () => {
+    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'));
+  });
+
+  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 = tabindexFixture.instantiate();
+    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 = sinon.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 = sinon.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.disabled = true;
+    });
+
+    for (const eventName of ['tap', 'click']) {
+      test('stops ' + eventName + ' event', () => {
+        const spy = addSpyOn(eventName);
+        MockInteractions.tap(element);
+        assert.isFalse(spy.called);
+      });
+    }
+
+    // Keycodes: 32 for Space, 13 for Enter.
+    for (const key of [32, 13]) {
+      test('stops click event on keycode ' + key, () => {
+        const tapSpy = sinon.spy();
+        element.addEventListener('click', tapSpy);
+        MockInteractions.pressAndReleaseKeyOn(element, key);
+        assert.isFalse(tapSpy.called);
+      });
+    }
+  });
+
+  suite('reporting', () => {
+    let reportStub;
+    setup(() => {
+      reportStub = sinon.stub(appContext.reportingService,
+          'reportInteraction');
+      reportStub.reset();
+    });
+
+    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#${basicFixture.fixtureId}>gr-button`,
+      });
+    });
+
+    test('report event after click on nested', () => {
+      element = nestedFixture.instantiate();
+      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#${nestedFixture.fixtureId}` +
+            `>div#test>gr-button.testBtn`,
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index 10e06dd..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @extends Polymer.Element */
-class GrChangeStar extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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' : '';
-  }
-
-  _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},
-    }));
-  }
-}
-
-customElements.define(GrChangeStar.is, GrChangeStar);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
new file mode 100644
index 0000000..1ecaf7f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -0,0 +1,89 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../gr-icons/gr-icons';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-star_html';
+import {customElement, property} from '@polymer/decorators';
+import {ChangeInfo} from '../../../types/common';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-star': GrChangeStar;
+  }
+}
+
+export interface ChangeStarToggleStarDetail {
+  change: ChangeInfo;
+  starred: boolean;
+}
+
+@customElement('gr-change-star')
+export class GrChangeStar extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when star state is toggled.
+   *
+   * @event toggle-star
+   */
+
+  @property({type: Object, notify: true})
+  change?: ChangeInfo;
+
+  _computeStarClass(starred: boolean) {
+    return starred ? 'active' : '';
+  }
+
+  _computeStarIcon(starred: boolean) {
+    // Hollow star is used to indicate inactive state.
+    return `gr-icons:star${starred ? '' : '-border'}`;
+  }
+
+  _computeAriaLabel(starred: boolean) {
+    return starred ? 'Unstar this change' : 'Star this change';
+  }
+
+  toggleStar() {
+    // Note: change should always be defined when use gr-change-star
+    // but since we don't have a good way to enforce usage to always
+    // set the change, we still check it here.
+    if (!this.change) {
+      return;
+    }
+    const newVal = !this.change.starred;
+    this.set('change.starred', newVal);
+    const detail: ChangeStarToggleStarDetail = {
+      change: this.change,
+      starred: newVal,
+    };
+    this.dispatchEvent(
+      new CustomEvent('toggle-star', {
+        bubbles: true,
+        composed: true,
+        detail,
+      })
+    );
+  }
+}
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
deleted file mode 100644
index f723717a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
new file mode 100644
index 0000000..6c0f6f7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
@@ -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';
+
+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
+    role="checkbox"
+    title="[[createTitle(Shortcut.TOGGLE_CHANGE_STAR,
+      ShortcutSection.ACTIONS)]]"
+    aria-label="[[_computeAriaLabel(change.starred)]]"
+    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
deleted file mode 100644
index 1ea9071..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ /dev/null
@@ -1,82 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-change-star</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-change-star></gr-change-star>
-  </template>
-</test-fixture>
-
-<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.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-star/gr-change-star_test.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
new file mode 100644
index 0000000..f05ed21
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-star.js';
+
+const basicFixture = fixtureFromElement('gr-change-star');
+
+suite('gr-change-star tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    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', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    element.addEventListener('toggle-star', resolve);
+    element.set('change.starred', false);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('button'));
+
+    await promise;
+    assert.equal(element.change.starred, true);
+  });
+
+  test('unstarring', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    element.addEventListener('toggle-star', resolve);
+    element.set('change.starred', true);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('button'));
+
+    await promise;
+    assert.equal(element.change.starred, false);
+  });
+});
+
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
deleted file mode 100644
index b99612e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.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 {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 ChangeStates = {
-  MERGED: 'Merged',
-  ABANDONED: 'Abandoned',
-  MERGE_CONFLICT: 'Merge Conflict',
-  WIP: 'WIP',
-  PRIVATE: 'Private',
-};
-
-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 PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
-    'current reviewers (or anyone with "View Private Changes" permission).';
-
-/** @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,
-        reflectToAttribute: true,
-      },
-      status: {
-        type: String,
-        observer: '_updateChipDetails',
-      },
-      tooltipText: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  _computeStatusString(status) {
-    if (status === ChangeStates.WIP && !this.flat) {
-      return 'Work in Progress';
-    }
-    return status;
-  }
-
-  _toClassName(str) {
-    return str.toLowerCase().replace(/\s/g, '-');
-  }
-
-  _updateChipDetails(status, previousStatus) {
-    if (previousStatus) {
-      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;
-    }
-  }
-}
-
-customElements.define(GrChangeStatus.is, GrChangeStatus);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
new file mode 100644
index 0000000..44ec4f5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -0,0 +1,104 @@
+/**
+ * @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 '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-tooltip-content/gr-tooltip-content';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-status_html';
+import {customElement, property} from '@polymer/decorators';
+
+enum ChangeStates {
+  MERGED = 'Merged',
+  ABANDONED = 'Abandoned',
+  MERGE_CONFLICT = 'Merge Conflict',
+  WIP = 'WIP',
+  PRIVATE = 'Private',
+}
+
+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 PRIVATE_TOOLTIP =
+  'This change is only visible to its owner and ' +
+  'current reviewers (or anyone with "View Private Changes" permission).';
+
+/** @extends PolymerElement */
+@customElement('gr-change-status')
+class GrChangeStatus extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, reflectToAttribute: true})
+  flat = false;
+
+  @property({type: String, observer: '_updateChipDetails'})
+  status?: ChangeStates;
+
+  @property({type: String})
+  tooltipText = '';
+
+  _computeStatusString(status: ChangeStates) {
+    if (status === ChangeStates.WIP && !this.flat) {
+      return 'Work in Progress';
+    }
+    return status;
+  }
+
+  _toClassName(str?: ChangeStates) {
+    return str ? str.toLowerCase().replace(/\s/g, '-') : '';
+  }
+
+  _updateChipDetails(status?: ChangeStates, previousStatus?: ChangeStates) {
+    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;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-status': 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
deleted file mode 100644
index 904ef1d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
new file mode 100644
index 0000000..542d8be
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
@@ -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';
+
+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: var(--status-merged);
+      color: var(--status-merged);
+    }
+    :host(.abandoned) .chip {
+      background-color: var(--status-abandoned);
+      color: var(--status-abandoned);
+    }
+    :host(.wip) .chip {
+      background-color: var(--status-wip);
+      color: var(--status-wip);
+    }
+    :host(.private) .chip {
+      background-color: var(--status-private);
+      color: var(--status-private);
+    }
+    :host(.merge-conflict) .chip {
+      background-color: var(--status-conflict);
+      color: var(--status-conflict);
+    }
+    :host(.active) .chip {
+      background-color: var(--status-active);
+      color: var(--status-active);
+    }
+    :host(.ready-to-submit) .chip {
+      background-color: var(--status-ready);
+      color: var(--status-ready);
+    }
+    :host(.custom) .chip {
+      background-color: var(--status-custom);
+      color: var(--status-custom);
+    }
+    :host([flat]) .chip {
+      background-color: transparent;
+      padding: 0;
+    }
+    :host(:not([flat])) .chip {
+      color: var(--status-text-color);
+    }
+  </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
deleted file mode 100644
index 806203b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
+++ /dev/null
@@ -1,137 +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">
-<meta charset="utf-8">
-<title>gr-change-status</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-change-status></gr-change-status>
-  </template>
-</test-fixture>
-
-<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 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;
-
-  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-change-status/gr-change-status_test.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
new file mode 100644
index 0000000..770a21c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
@@ -0,0 +1,117 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-change-status.js';
+
+const basicFixture = fixtureFromElement('gr-change-status');
+
+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 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;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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'));
+  });
+});
+
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
deleted file mode 100644
index ccfc44c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ /dev/null
@@ -1,544 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const UNRESOLVED_EXPAND_COUNT = 5;
-const NEWLINE_PATTERN = /\n/g;
-
-/**
- * @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; }
-
-  static get is() { return 'gr-comment-thread'; }
-  /**
-   * Fired when the thread should be discarded.
-   *
-   * @event thread-discard
-   */
-
-  /**
-   * 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,
-        value() { return []; },
-      },
-      /**
-       * @type {?{start_line: number, start_character: number, end_line: number,
-       *          end_character: number}}
-       */
-      range: {
-        type: Object,
-        reflectToAttribute: true,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      commentSide: {
-        type: String,
-        reflectToAttribute: true,
-      },
-      patchNum: String,
-      path: String,
-      projectName: {
-        type: String,
-        observer: '_projectNameChanged',
-      },
-      hasDraft: {
-        type: Boolean,
-        notify: true,
-        reflectToAttribute: true,
-      },
-      isOnParent: {
-        type: Boolean,
-        value: false,
-      },
-      parentIndex: {
-        type: Number,
-        value: null,
-      },
-      rootId: {
-        type: String,
-        notify: true,
-        computed: '_computeRootId(comments.*)',
-      },
-      /**
-       * If this is true, the comment thread also needs to have the change and
-       * line properties property set
-       */
-      showFilePath: {
-        type: Boolean,
-        value: false,
-      },
-      /** Necessary only if showFilePath is true or when used with gr-diff */
-      lineNum: {
-        type: Number,
-        reflectToAttribute: true,
-      },
-      unresolved: {
-        type: Boolean,
-        notify: true,
-        reflectToAttribute: true,
-      },
-      _showActions: Boolean,
-      _lastComment: Object,
-      _orderedComments: Array,
-      _projectConfig: Object,
-      isRobotComment: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_commentsChanged(comments.*)',
-    ];
-  }
-
-  get keyBindings() {
-    return {
-      'e shift+e': '_handleEKey',
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('comment-update',
-        e => this._handleCommentUpdate(e));
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getLoggedIn().then(loggedIn => {
-      this._showActions = loggedIn;
-    });
-    this._setInitialExpandedState();
-  }
-
-  addOrEditDraft(opt_lineNum, opt_range) {
-    const lastComment = this.comments[this.comments.length - 1] || {};
-    if (lastComment.__draft) {
-      const commentEl = this._commentElWithDraftID(
-          lastComment.id || lastComment.__draftID);
-      commentEl.editing = true;
-
-      // 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);
-    }
-  }
-
-  addDraft(opt_lineNum, opt_range, opt_unresolved) {
-    const draft = this._newDraft(opt_lineNum, opt_range);
-    draft.__editing = true;
-    draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
-    this.push('comments', draft);
-  }
-
-  fireRemoveSelf() {
-    this.dispatchEvent(new CustomEvent('thread-discard',
-        {detail: {rootId: this.rootId}, bubbles: false}));
-  }
-
-  _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
-    return GerritNav.getUrlForDiffById(changeNum,
-        projectName, path, patchNum,
-        null, this.lineNum);
-  }
-
-  _computeDisplayPath(path) {
-    const lineString = this.lineNum ? `#${this.lineNum}` : '';
-    return this.computeDisplayPath(path) + lineString;
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _commentsChanged() {
-    this._orderedComments = this._sortedComments(this.comments);
-    this.updateThreadProperties();
-  }
-
-  updateThreadProperties() {
-    if (this._orderedComments.length) {
-      this._lastComment = this._getLastComment();
-      this.unresolved = this._lastComment.unresolved;
-      this.hasDraft = this._lastComment.__draft;
-      this.isRobotComment = !!(this._lastComment.robot_id);
-    }
-  }
-
-  _shouldDisableAction(_showActions, _lastComment) {
-    return !_showActions || !_lastComment || !!_lastComment.__draft;
-  }
-
-  _hideActions(_showActions, _lastComment) {
-    return this._shouldDisableAction(_showActions, _lastComment) ||
-      !!_lastComment.robot_id;
-  }
-
-  _getLastComment() {
-    return this._orderedComments[this._orderedComments.length - 1] || {};
-  }
-
-  _handleEKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    // 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);
-    });
-  }
-
-  _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 (opt_isEditing) {
-      reply.__editing = true;
-    }
-
-    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);
-    }
-  }
-
-  _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;
-    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;
-  }
-
-  _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 (this.parentIndex) {
-      d.parent = this.parentIndex;
-    }
-    return d;
-  }
-
-  _getSide(isOnParent) {
-    if (isOnParent) { return 'PARENT'; }
-    return 'REVISION';
-  }
-
-  _computeRootId(comments) {
-    // Keep the root ID even if the comment was removed, so that notification
-    // 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);
-      }
-    }
-  }
-
-  _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) {
-    if (this.isRobotComment) {
-      return 'robotComment';
-    }
-    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.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
new file mode 100644
index 0000000..878c01c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -0,0 +1,648 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-storage/gr-storage';
+import '../gr-comment/gr-comment';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-comment-thread_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+  isDraft,
+  isRobot,
+  sortComments,
+  UIComment,
+  UIDraft,
+  UIRobot,
+} from '../../../utils/comment-util';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {CommentSide, Side, SpecialFilePath} from '../../../constants/constants';
+import {computeDisplayPath} from '../../../utils/path-list-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  CommentRange,
+  ConfigInfo,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {GrComment} from '../gr-comment/gr-comment';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+const UNRESOLVED_EXPAND_COUNT = 5;
+const NEWLINE_PATTERN = /\n/g;
+
+export interface GrCommentThread {
+  $: {
+    restAPI: RestApiService & Element;
+    storage: GrStorage;
+  };
+}
+
+@customElement('gr-comment-thread')
+export class GrCommentThread extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  // KeyboardShortcutMixin Not used in this element rather other elements tests
+
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the thread should be discarded.
+   *
+   * @event thread-discard
+   */
+
+  /**
+   * 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.
+   */
+  @property({type: Number})
+  changeNum?: NumericChangeId;
+
+  @property({type: Array})
+  comments: UIComment[] = [];
+
+  @property({type: Object, reflectToAttribute: true})
+  range?: CommentRange;
+
+  @property({type: Object})
+  keyEventTarget: HTMLElement = document.body;
+
+  @property({type: String, reflectToAttribute: true})
+  commentSide?: Side;
+
+  @property({type: String})
+  patchNum?: PatchSetNum;
+
+  @property({type: String})
+  path?: string;
+
+  @property({type: String, observer: '_projectNameChanged'})
+  projectName?: RepoName;
+
+  @property({type: Boolean, notify: true, reflectToAttribute: true})
+  hasDraft?: boolean;
+
+  @property({type: Boolean})
+  isOnParent = false;
+
+  @property({type: Number})
+  parentIndex: number | null = null;
+
+  @property({
+    type: String,
+    notify: true,
+    computed: '_computeRootId(comments.*)',
+  })
+  rootId?: UrlEncodedCommentId;
+
+  @property({type: Boolean})
+  showFilePath = false;
+
+  @property({type: Number, reflectToAttribute: true})
+  lineNum?: number;
+
+  @property({type: Boolean, notify: true, reflectToAttribute: true})
+  unresolved?: boolean;
+
+  @property({type: Boolean})
+  _showActions?: boolean;
+
+  @property({type: Object})
+  _lastComment?: UIComment;
+
+  @property({type: Array})
+  _orderedComments: UIComment[] = [];
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  isRobotComment = false;
+
+  @property({type: Boolean})
+  showFileName = true;
+
+  @property({type: Boolean})
+  showPatchset = true;
+
+  get keyBindings() {
+    return {
+      'e shift+e': '_handleEKey',
+    };
+  }
+
+  reporting = appContext.reportingService;
+
+  flagsService = appContext.flagsService;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('comment-update', e =>
+      this._handleCommentUpdate(e as CustomEvent)
+    );
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._showActions = loggedIn;
+    });
+    this._setInitialExpandedState();
+  }
+
+  addOrEditDraft(lineNum?: number, rangeParam?: CommentRange) {
+    const lastComment = this.comments[this.comments.length - 1] || {};
+    if (isDraft(lastComment)) {
+      const commentEl = this._commentElWithDraftID(
+        lastComment.id || lastComment.__draftID
+      );
+      if (!commentEl) throw new Error('Failed to find draft.');
+      commentEl.editing = true;
+
+      // If the comment was collapsed, re-open it to make it clear which
+      // actions are available.
+      commentEl.collapsed = false;
+    } else {
+      const range = rangeParam
+        ? rangeParam
+        : lastComment
+        ? lastComment.range
+        : undefined;
+      const unresolved = lastComment ? lastComment.unresolved : undefined;
+      this.addDraft(lineNum, range, unresolved);
+    }
+  }
+
+  addDraft(lineNum?: number, range?: CommentRange, unresolved?: boolean) {
+    const draft = this._newDraft(lineNum, range);
+    draft.__editing = true;
+    draft.unresolved = unresolved === false ? unresolved : true;
+    this.push('comments', draft);
+  }
+
+  fireRemoveSelf() {
+    this.dispatchEvent(
+      new CustomEvent('thread-discard', {
+        detail: {rootId: this.rootId},
+        bubbles: false,
+      })
+    );
+  }
+
+  _getDiffUrlForPath(path: string) {
+    if (!this.changeNum) throw new Error('changeNum is missing');
+    if (!this.projectName) throw new Error('projectName is missing');
+    if (isDraft(this.comments[0])) {
+      return GerritNav.getUrlForDiffById(
+        this.changeNum,
+        this.projectName,
+        path,
+        this.patchNum
+      );
+    }
+    const id = this.comments[0].id;
+    if (!id) throw new Error('A published comment is missing the id.');
+    return GerritNav.getUrlForComment(this.changeNum, this.projectName, id);
+  }
+
+  _getDiffUrlForComment(
+    projectName?: RepoName,
+    changeNum?: NumericChangeId,
+    path?: string,
+    patchNum?: PatchSetNum
+  ) {
+    if (!projectName || !changeNum || !path) return undefined;
+    if (
+      (this.comments.length && this.comments[0].side === 'PARENT') ||
+      isDraft(this.comments[0])
+    ) {
+      return GerritNav.getUrlForDiffById(
+        changeNum,
+        projectName,
+        path,
+        patchNum,
+        undefined,
+        this.lineNum
+      );
+    }
+    const id = this.comments[0].id;
+    if (!id) throw new Error('A published comment is missing the id.');
+    return GerritNav.getUrlForComment(changeNum, projectName, id);
+  }
+
+  _isPatchsetLevelComment(path: string) {
+    return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+  }
+
+  _computeDisplayPath(path: string) {
+    const displayPath = computeDisplayPath(path);
+    if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return 'Patchset';
+    }
+    return displayPath;
+  }
+
+  _computeDisplayLine() {
+    if (this.lineNum) return `#${this.lineNum}`;
+    // If range is set, then lineNum equals the end line of the range.
+    if (!this.lineNum && !this.range) {
+      if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+        return '';
+      }
+      return 'FILE';
+    }
+    if (this.range) return `#${this.range.end_line}`;
+    return '';
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  @observe('comments.*')
+  _commentsChanged() {
+    this._orderedComments = sortComments(this.comments);
+    this.updateThreadProperties();
+  }
+
+  updateThreadProperties() {
+    if (this._orderedComments.length) {
+      this._lastComment = this._getLastComment();
+      this.unresolved = this._lastComment.unresolved;
+      this.hasDraft = isDraft(this._lastComment);
+      this.isRobotComment = isRobot(this._lastComment);
+    }
+  }
+
+  _shouldDisableAction(_showActions?: boolean, _lastComment?: UIComment) {
+    return !_showActions || !_lastComment || isDraft(_lastComment);
+  }
+
+  _hideActions(_showActions?: boolean, _lastComment?: UIComment) {
+    return (
+      this._shouldDisableAction(_showActions, _lastComment) ||
+      isRobot(_lastComment)
+    );
+  }
+
+  _getLastComment() {
+    return this._orderedComments[this._orderedComments.length - 1] || {};
+  }
+
+  _handleEKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    // 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: boolean) {
+    const comments = this.root?.querySelectorAll('gr-comment');
+    if (!comments) return;
+    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 as UIRobot).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;
+        }
+      }
+    }
+  }
+
+  _createReplyComment(
+    content?: string,
+    isEditing?: boolean,
+    unresolved?: boolean
+  ) {
+    this.reporting.recordDraftInteraction();
+    const id = this._orderedComments[this._orderedComments.length - 1].id;
+    if (!id) throw new Error('Cannot reply to comment without id.');
+    const reply = this._newReply(id, content, unresolved);
+
+    // 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 (isEditing) {
+      reply.__editing = true;
+    }
+
+    this.push('comments', reply);
+
+    if (!isEditing) {
+      // Allow the reply to render in the dom-repeat.
+      this.async(() => {
+        const commentEl = this._commentElWithDraftID(reply.__draftID);
+        if (commentEl) commentEl.save();
+      }, 1);
+    }
+  }
+
+  _isDraft(comment: UIComment) {
+    return isDraft(comment);
+  }
+
+  _processCommentReply(quote?: boolean) {
+    const comment = this._lastComment;
+    if (!comment) throw new Error('Failed to find last comment.');
+    let content = undefined;
+    if (quote) {
+      const msg = comment.message;
+      if (!msg) throw new Error('Quoting empty comment.');
+      content = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+    }
+    this._createReplyComment(content, true, comment.unresolved);
+  }
+
+  _handleCommentReply() {
+    this._processCommentReply();
+  }
+
+  _handleCommentQuote() {
+    this._processCommentReply(true);
+  }
+
+  _handleCommentAck() {
+    this._createReplyComment('Ack', false, false);
+  }
+
+  _handleCommentDone() {
+    this._createReplyComment('Done', false, false);
+  }
+
+  _handleCommentFix(e: CustomEvent) {
+    const comment = e.detail.comment;
+    const msg = comment.message;
+    const quoted = msg.replace(NEWLINE_PATTERN, '\n> ') as string;
+    const quoteStr = '> ' + quoted + '\n\n';
+    const response = quoteStr + 'Please fix.';
+    this._createReplyComment(response, false, true);
+  }
+
+  _commentElWithDraftID(id?: string): GrComment | null {
+    if (!id) return null;
+    const els = this.root?.querySelectorAll('gr-comment');
+    if (!els) return null;
+    for (const el of els) {
+      const c = el.comment;
+      if (isRobot(c)) continue;
+      if (c?.id === id || (isDraft(c) && c?.__draftID === id)) return el;
+    }
+    return null;
+  }
+
+  _newReply(
+    inReplyTo: UrlEncodedCommentId,
+    message?: string,
+    unresolved?: boolean
+  ) {
+    const d = this._newDraft();
+    d.in_reply_to = inReplyTo;
+    if (message !== undefined) {
+      d.message = message;
+    }
+    if (unresolved !== undefined) {
+      d.unresolved = unresolved;
+    }
+    return d;
+  }
+
+  _newDraft(lineNum?: number, range?: CommentRange) {
+    const d: UIDraft = {
+      __draft: true,
+      __draftID: Math.random().toString(36),
+      __date: new Date(),
+    };
+
+    // For replies, always use same meta info as root.
+    if (this.comments && this.comments.length >= 1) {
+      const rootComment = this.comments[0];
+      if (rootComment.path !== undefined) d.path = rootComment.path;
+      if (rootComment.patch_set !== undefined)
+        d.patch_set = rootComment.patch_set;
+      if (rootComment.side !== undefined) d.side = rootComment.side;
+      if (rootComment.__commentSide !== undefined)
+        d.__commentSide = rootComment.__commentSide;
+      if (rootComment.line !== undefined) d.line = rootComment.line;
+      if (rootComment.range !== undefined) d.range = rootComment.range;
+      if (rootComment.parent !== undefined) d.parent = rootComment.parent;
+    } else {
+      // Set meta info for root comment.
+      d.path = this.path;
+      d.patch_set = this.patchNum;
+      d.side = this._getSide(this.isOnParent);
+      d.__commentSide = this.commentSide;
+
+      if (lineNum) {
+        d.line = lineNum;
+      }
+      if (range) {
+        d.range = range;
+      }
+      if (this.parentIndex) {
+        d.parent = this.parentIndex;
+      }
+    }
+    return d;
+  }
+
+  _getSide(isOnParent: boolean): CommentSide {
+    return isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
+  }
+
+  _computeRootId(comments: PolymerDeepPropertyChange<UIComment[], unknown>) {
+    // 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];
+    if (rootComment.id) return rootComment.id;
+    if (isDraft(rootComment)) return rootComment.__draftID;
+    throw new Error('Missing id in root comment.');
+  }
+
+  _handleCommentDiscard(e: Event) {
+    if (!this.changeNum) throw new Error('changeNum is missing');
+    if (!this.patchNum) throw new Error('patchNum is missing');
+    const diffCommentEl = (dom(e) as EventApi).rootTarget as GrComment;
+    const comment = diffCommentEl.comment;
+    const idx = this._indexOf(comment, this.comments);
+    if (idx === -1) {
+      throw new Error(
+        'Cannot find comment ' + JSON.stringify(diffCommentEl.comment)
+      );
+    }
+    this.splice('comments', idx, 1);
+    if (this.comments.length === 0) {
+      this.fireRemoveSelf();
+    }
+    this._handleCommentSavedOrDiscarded();
+
+    // 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: StorageLocation = {
+          changeNum: this.changeNum,
+          patchNum: this.patchNum,
+          path: changeComment.path,
+          line: changeComment.line,
+        };
+        this.$.storage.setDraftComment(
+          commentLocation,
+          changeComment.message ?? ''
+        );
+      }
+    }
+  }
+
+  _handleCommentSavedOrDiscarded() {
+    this.dispatchEvent(
+      new CustomEvent('thread-changed', {
+        detail: {rootId: this.rootId, path: this.path},
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCommentUpdate(e: CustomEvent) {
+    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: UIComment | undefined, arr: UIComment[]) {
+    if (!comment) return -1;
+    for (let i = 0; i < arr.length; i++) {
+      const c = arr[i];
+      if (
+        (isDraft(c) && isDraft(comment) && c.__draftID === comment.__draftID) ||
+        (c.id && c.id === comment.id)
+      ) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  _computeHostClass(unresolved?: boolean) {
+    if (this.isRobotComment) {
+      return 'robotComment';
+    }
+    return unresolved ? 'unresolved' : '';
+  }
+
+  /**
+   * Load the project config when a project name has been provided.
+   *
+   * @param name The project name.
+   */
+  _projectNameChanged(name?: RepoName) {
+    if (!name) {
+      return;
+    }
+    this.$.restAPI.getProjectConfig(name).then(config => {
+      this._projectConfig = config;
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-comment-thread': 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
deleted file mode 100644
index fbc18b4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
new file mode 100644
index 0000000..4c15383
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -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';
+
+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 {
+      border-bottom: 1px solid var(--comment-separator-color);
+    }
+    #actions {
+      margin-left: auto;
+      padding: var(--spacing-s) var(--spacing-m);
+    }
+    #container {
+      background-color: var(--comment-background-color);
+      color: var(--comment-text-color);
+      display: var(--gr-comment-thread-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 {
+      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;
+    }
+    .fileName {
+      padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
+    }
+  </style>
+  <template is="dom-if" if="[[showFilePath]]">
+    <template is="dom-if" if="[[showFileName]]">
+      <div class="fileName">
+        <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
+          <span> [[_computeDisplayPath(path)]] </span>
+        </template>
+        <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
+          <a href$="[[_getDiffUrlForPath(path)]]">
+            [[_computeDisplayPath(path)]]
+          </a>
+        </template>
+      </div>
+    </template>
+    <div class="pathInfo">
+      <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
+        <a
+          href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
+          >[[_computeDisplayLine()]]</a
+        >
+      </template>
+    </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]]"
+        show-patchset="[[showPatchset]]"
+        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-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
deleted file mode 100644
index 244a9ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
+++ /dev/null
@@ -1,877 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-comment-thread</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-comment-thread></gr-comment-thread>
-  </template>
-</test-fixture>
-
-<test-fixture id="withComment">
-  <template>
-    <gr-comment-thread></gr-comment-thread>
-  </template>
-</test-fixture>
-
-<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;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('comments are sorted correctly', () => {
-      const comments = [
-        {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        },
-      ];
-      const results = element._sortedComments(comments);
-      assert.deepEqual(results, [
-        {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        },
-      ]);
-    });
-
-    test('addOrEditDraft w/ edit draft', () => {
-      element.comments = [{
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        in_reply_to: 'sallys_confession',
-        updated: '2015-12-25 15:00:20.396000000',
-        __draft: true,
-      }];
-      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          () => { return {}; });
-      const addDraftStub = sandbox.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isTrue(commentElStub.called);
-      assert.isFalse(addDraftStub.called);
-    });
-
-    test('addOrEditDraft w/o edit draft', () => {
-      element.comments = [];
-      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          () => { return {}; });
-      const addDraftStub = sandbox.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isFalse(commentElStub.called);
-      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 = {};
-      assert.equal(element._hideActions(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      const robotComment = {};
-      robotComment.robot_id = true;
-      assert.equal(element._hideActions(showActions, robotComment), true);
-    });
-
-    test('setting project name loads the project config', done => {
-      const projectName = 'foo/bar/baz';
-      const getProjectStub = sandbox.stub(element.$.restAPI, 'getProjectConfig')
-          .returns(Promise.resolve({}));
-      element.projectName = projectName;
-      flush(() => {
-        assert.isTrue(getProjectStub.calledWithExactly(projectName));
-        done();
-      });
-    });
-
-    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.shadowRoot
-          .querySelector('.pathInfo'));
-
-      sandbox.stub(GerritNav, 'getUrlForDiffById');
-      element.changeNum = 123;
-      element.projectName = 'test project';
-      element.path = 'path/to/file';
-      element.patchNum = 3;
-      element.lineNum = 5;
-      element.showFilePath = true;
-      flushAsynchronousOperations();
-      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));
-    });
-
-    test('_computeDisplayPath', () => {
-      const path = 'path/to/file';
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file#5');
-    });
-  });
-});
-
-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',
-      unresolved: true,
-    }];
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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 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.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.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-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
new file mode 100644
index 0000000..1833b73
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
@@ -0,0 +1,879 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-comment-thread.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+import {sortComments} from '../../../utils/comment-util.js';
+
+const basicFixture = fixtureFromElement('gr-comment-thread');
+
+const withCommentFixture = fixtureFromElement('gr-comment-thread');
+
+suite('gr-comment-thread tests', () => {
+  suite('basic test', () => {
+    let element;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+
+      element = basicFixture.instantiate();
+      element.patchNum = '3';
+      element.changeNum = '1';
+      flush();
+    });
+
+    test('comments are sorted correctly', () => {
+      const comments = [
+        {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          __date: new Date('2015-12-25'),
+        }, {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }, {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000',
+        },
+      ];
+      const results = sortComments(comments);
+      assert.deepEqual(results, [
+        {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }, {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          __date: new Date('2015-12-25'),
+        },
+      ]);
+    });
+
+    test('addOrEditDraft w/ edit draft', () => {
+      element.comments = [{
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        in_reply_to: 'sallys_confession',
+        updated: '2015-12-25 15:00:20.396000000',
+        __draft: true,
+      }];
+      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
+          .callsFake(() => { return {}; });
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isTrue(commentElStub.called);
+      assert.isFalse(addDraftStub.called);
+    });
+
+    test('addOrEditDraft w/o edit draft', () => {
+      element.comments = [];
+      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
+          .callsFake(() => { return {}; });
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isFalse(commentElStub.called);
+      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 = {};
+      assert.equal(element._hideActions(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      const robotComment = {};
+      robotComment.robot_id = true;
+      assert.equal(element._hideActions(showActions, robotComment), true);
+    });
+
+    test('setting project name loads the project config', done => {
+      const projectName = 'foo/bar/baz';
+      const getProjectStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
+          .returns(Promise.resolve({}));
+      element.projectName = projectName;
+      flush(() => {
+        assert.isTrue(getProjectStub.calledWithExactly(projectName));
+        done();
+      });
+    });
+
+    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.shadowRoot
+          .querySelector('.pathInfo'));
+
+      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
+      element.changeNum = 123;
+      element.projectName = 'test project';
+      element.path = 'path/to/file';
+      element.latestPatchNum = 10;
+      element.patchNum = 3;
+      element.lineNum = 5;
+      element.comments = [{id: 'comment_id'}];
+      element.showFilePath = true;
+      flush();
+      assert.isOk(element.shadowRoot
+          .querySelector('.pathInfo'));
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.pathInfo')).display,
+      'none');
+      assert.isTrue(commentStub.calledWithExactly(
+          element.changeNum, element.projectName, 'comment_id'));
+    });
+
+    test('_computeDisplayPath', () => {
+      let path = 'path/to/file';
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.patchNum = '3';
+      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayPath(path), 'Patchset');
+    });
+
+    test('_computeDisplayLine', () => {
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.path = SpecialFilePath.COMMIT_MESSAGE;
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.lineNum = undefined;
+      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayLine(), '');
+    });
+  });
+});
+
+suite('comment action tests with unresolved thread', () => {
+  let element;
+
+  setup(() => {
+    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 = withCommentFixture.instantiate();
+    element.patchNum = '1';
+    element.changeNum = '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:33.843000000',
+      path: '/path/to/file.txt',
+      unresolved: true,
+      patch_set: 3,
+      __commentSide: 'left',
+    }];
+    flush();
+  });
+
+  test('reply', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const reportStub = sinon.stub(element.reporting,
+        'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const replyBtn = element.$.replyBtn;
+    MockInteractions.tap(replyBtn);
+    flush();
+
+    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 = sinon.stub(element.reporting,
+        'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    MockInteractions.tap(quoteBtn);
+    flush();
+
+    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 = sinon.stub(element.reporting,
+        'recordDraftInteraction');
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      path: 'test',
+      line: 5,
+      message: 'is this a crossover episode!?\nIt might be!',
+      updated: '2015-12-08 19:48:33.843000000',
+    }];
+    flush();
+
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    MockInteractions.tap(quoteBtn);
+    flush();
+
+    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 = sinon.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.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 = sinon.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 = sinon.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].path,
+        'it’s pronouced jiff, not giff'));
+    flush();
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl =
+        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');
+        flush();
+        const rootId = element.rootId;
+        assert.isOk(rootId);
+
+        const saveOrDiscardStub = sinon.stub();
+        element.addEventListener('thread-changed', saveOrDiscardStub);
+        const draftEl =
+        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,
+      path: 'test',
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    }];
+
+    const replyBtn = element.$.replyBtn;
+    MockInteractions.tap(replyBtn);
+    flush();
+
+    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',
+      path: 'test',
+      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',
+      path: 'test',
+      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',
+      path: 'test',
+      line: 5,
+      message: 'no',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    }];
+    const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+    flush();
+
+    const draftEl =
+    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('dummy', true);
+      flush();
+      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('dummy', true, true);
+      flush();
+      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 with root', () => {
+    const draft = element._newDraft();
+    assert.equal(draft.__commentSide, 'left');
+    assert.equal(draft.patch_set, 3);
+  });
+
+  test('_newDraft with no root', () => {
+    element.comments = [];
+    element.commentSide = 'right';
+    element.patchNum = 2;
+    const draft = element._newDraft();
+    assert.equal(draft.__commentSide, 'right');
+    assert.equal(draft.patch_set, 2);
+  });
+
+  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;
+
+  setup(() => {
+    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 = withCommentFixture.instantiate();
+    element.patchNum = '1';
+    element.changeNum = '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:33.843000000',
+      path: '/path/to/file.txt',
+      unresolved: false,
+    }];
+    flush();
+  });
+
+  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);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
deleted file mode 100644
index ee9df11..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ /dev/null
@@ -1,864 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVING_MESSAGE = 'Saving';
-const DRAFT_SINGULAR = 'draft...';
-const DRAFT_PLURAL = 'drafts...';
-const SAVED_MESSAGE = 'All changes saved';
-
-const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
-const FILE = 'FILE';
-
-/**
- * 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.',
-];
-
-/**
- * @extends Polymer.Element
- */
-class GrComment extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-comment'; }
-  /**
-   * Fired when the create fix comment action is triggered.
-   *
-   * @event create-fix-comment
-   */
-
-  /**
-   * Fired when the show fix preview action is triggered.
-   *
-   * @event open-fix-preview
-   */
-
-  /**
-   * Fired when this comment is discarded.
-   *
-   * @event comment-discard
-   */
-
-  /**
-   * 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 {!Gerrit.Comment} */
-      comment: {
-        type: Object,
-        notify: true,
-        observer: '_commentChanged',
-      },
-      comments: {
-        type: Array,
-      },
-      isRobotComment: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      draft: {
-        type: Boolean,
-        value: false,
-        observer: '_draftChanged',
-      },
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_editingChanged',
-      },
-      discarding: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      hasChildren: Boolean,
-      patchNum: String,
-      showActions: Boolean,
-      _showHumanActions: Boolean,
-      _showRobotActions: Boolean,
-      collapsed: {
-        type: Boolean,
-        value: true,
-        observer: '_toggleCollapseClass',
-      },
-      /** @type {?} */
-      projectConfig: Object,
-      robotButtonDisabled: Boolean,
-      _hasHumanReply: Boolean,
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-
-      _xhrPromise: Object, // Used for testing.
-      _messageText: {
-        type: String,
-        value: '',
-        observer: '_messageTextChanged',
-      },
-      commentSide: String,
-      side: String,
-
-      resolved: Boolean,
-
-      _numPendingDraftRequests: {
-        type: Object,
-        value:
-          {number: 0}, // Intentional to share the object across instances.
-      },
-
-      _enableOverlay: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Property for storing references to overlay elements. When the overlays
-       * 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: () => { return {}; },
-      },
-
-      _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)',
-    ];
-  }
-
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
-      'esc': '_handleEsc',
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    if (this.editing) {
-      this.collapsed = false;
-    } else if (this.comment) {
-      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;
-      });
-    })
-        .catch(err => {
-          this.disabled = false;
-          throw err;
-        });
-
-    return this._xhrPromise;
-  }
-
-  _eraseDraftComment() {
-    // Prevents a race condition in which removing the draft comment occurs
-    // prior to it being saved.
-    this.cancelDebouncer('store');
-
-    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,
-      };
-
-      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);
-  }
-
-  _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,
-      },
-    }));
-  }
-
-  _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._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.dispatchEvent(new CustomEvent('comment-update', {
-      detail: payload,
-      composed: true, bubbles: true,
-    }));
-    if (!this.editing) {
-      // Save the resolved state immediately.
-      this.save(payload.comment);
-    }
-  }
-
-  _handleCommentDelete() {
-    this._openOverlay(this.confirmDeleteOverlay);
-  }
-
-  _handleCancelDeleteComment() {
-    this._closeOverlay(this.confirmDeleteOverlay);
-  }
-
-  _openOverlay(overlay) {
-    dom(getRootElement()).appendChild(overlay);
-    return overlay.open();
-  }
-
-  _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 '';
-  }
-
-  _computeHideRunDetails(comment, collapsed) {
-    if (!comment) return true;
-    return !(comment.robot_id && comment.url && !collapsed);
-  }
-
-  _closeOverlay(overlay) {
-    dom(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.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
new file mode 100644
index 0000000..e4d520f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -0,0 +1,1045 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../gr-button/gr-button';
+import '../gr-dialog/gr-dialog';
+import '../gr-date-formatter/gr-date-formatter';
+import '../gr-formatted-text/gr-formatted-text';
+import '../gr-icons/gr-icons';
+import '../gr-overlay/gr-overlay';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-storage/gr-storage';
+import '../gr-textarea/gr-textarea';
+import '../gr-tooltip-content/gr-tooltip-content';
+import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
+import '../gr-account-label/gr-account-label';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-comment_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {getRootElement} from '../../../scripts/rootElement';
+import {appContext} from '../../../services/app-context';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrTextarea} from '../gr-textarea/gr-textarea';
+import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
+import {GrOverlay} from '../gr-overlay/gr-overlay';
+import {
+  AccountDetailInfo,
+  NumericChangeId,
+  ConfigInfo,
+  PatchSetNum,
+} from '../../../types/common';
+import {GrButton} from '../gr-button/gr-button';
+import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
+import {GrDialog} from '../gr-dialog/gr-dialog';
+import {Side} from '../../../constants/constants';
+import {
+  isDraft,
+  UIComment,
+  UIDraft,
+  UIRobot,
+} from '../../../utils/comment-util';
+import {OpenFixPreviewEventDetail} from '../../../types/events';
+
+const STORAGE_DEBOUNCE_INTERVAL = 400;
+const TOAST_DEBOUNCE_INTERVAL = 200;
+
+const SAVING_MESSAGE = 'Saving';
+const DRAFT_SINGULAR = 'draft...';
+const DRAFT_PLURAL = 'drafts...';
+const SAVED_MESSAGE = 'All changes saved';
+const UNSAVED_MESSAGE = 'Unable to save draft';
+
+const REPORT_CREATE_DRAFT = 'CreateDraftComment';
+const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
+const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
+
+const FILE = 'FILE';
+
+export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
+
+/**
+ * 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.',
+];
+
+interface CommentOverlays {
+  confirmDelete?: GrOverlay | null;
+  confirmDiscard?: GrOverlay | null;
+}
+
+export interface GrComment {
+  $: {
+    restAPI: RestApiService & Element;
+    storage: GrStorage;
+    container: HTMLDivElement;
+    resolvedCheckbox: HTMLInputElement;
+  };
+}
+
+@customElement('gr-comment')
+export class GrComment extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the create fix comment action is triggered.
+   *
+   * @event create-fix-comment
+   */
+
+  /**
+   * Fired when the show fix preview action is triggered.
+   *
+   * @event open-fix-preview
+   */
+
+  /**
+   * Fired when this comment is discarded.
+   *
+   * @event comment-discard
+   */
+
+  /**
+   * 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
+   */
+
+  @property({type: Number})
+  changeNum?: NumericChangeId;
+
+  @property({type: Object, notify: true, observer: '_commentChanged'})
+  comment?: UIComment | UIRobot;
+
+  @property({type: Array})
+  comments?: (UIComment | UIRobot)[];
+
+  @property({type: Boolean, reflectToAttribute: true})
+  isRobotComment = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  @property({type: Boolean, observer: '_draftChanged'})
+  draft = false;
+
+  @property({type: Boolean, observer: '_editingChanged'})
+  editing = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  discarding = false;
+
+  @property({type: Boolean})
+  hasChildren?: boolean;
+
+  @property({type: String})
+  patchNum?: PatchSetNum;
+
+  @property({type: Boolean})
+  showActions?: boolean;
+
+  @property({type: Boolean})
+  _showHumanActions?: boolean;
+
+  @property({type: Boolean})
+  _showRobotActions?: boolean;
+
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+    observer: '_toggleCollapseClass',
+  })
+  collapsed = true;
+
+  @property({type: Object})
+  projectConfig?: ConfigInfo;
+
+  @property({type: Boolean})
+  robotButtonDisabled?: boolean;
+
+  @property({type: Boolean})
+  _hasHumanReply?: boolean;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  @property({type: Object})
+  _xhrPromise?: Promise<any>; // Used for testing.
+
+  @property({type: String, observer: '_messageTextChanged'})
+  _messageText = '';
+
+  @property({type: String})
+  commentSide?: Side;
+
+  @property({type: String})
+  side?: string;
+
+  @property({type: Boolean})
+  resolved?: boolean;
+
+  // Intentional to share the object across instances.
+  @property({type: Object})
+  _numPendingDraftRequests: {number: number} = {number: 0};
+
+  @property({type: Boolean})
+  _enableOverlay = false;
+
+  /**
+   * Property for storing references to overlay elements. When the overlays
+   * 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.
+   */
+  @property({type: Object})
+  _overlays: CommentOverlays = {};
+
+  @property({type: Boolean})
+  _showRespectfulTip = false;
+
+  @property({type: Boolean})
+  showPatchset = true;
+
+  @property({type: String})
+  _respectfulReviewTip?: string;
+
+  @property({type: Boolean})
+  _respectfulTipDismissed = false;
+
+  @property({type: Boolean})
+  _unableToSave = false;
+
+  @property({type: Object})
+  _selfAccount?: AccountDetailInfo;
+
+  get keyBindings() {
+    return {
+      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
+      esc: '_handleEsc',
+    };
+  }
+
+  reporting = appContext.reportingService;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI.getAccount().then(account => {
+      this._selfAccount = account;
+    });
+    if (this.editing) {
+      this.collapsed = false;
+    } else if (this.comment) {
+      this.collapsed = !!this.comment.collapsed;
+    }
+    this._getIsAdmin().then(isAdmin => {
+      this._isAdmin = !!isAdmin;
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancelDebouncer('fire-update');
+    if (this.textarea) {
+      this.textarea.closeDropdown();
+    }
+  }
+
+  _getAuthor(comment: UIComment) {
+    return comment.author || this._selfAccount;
+  }
+
+  @observe('editing')
+  _onEditingChange(editing?: boolean) {
+    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: number, max: number) {
+    return Math.floor(Math.random() * (max - min) + min);
+  }
+
+  _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
+    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(): GrTextarea | null {
+    return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
+  }
+
+  get confirmDeleteOverlay() {
+    if (!this._overlays.confirmDelete) {
+      this._enableOverlay = true;
+      flush();
+      this._overlays.confirmDelete = this.shadowRoot?.querySelector(
+        '#confirmDeleteOverlay'
+      ) as GrOverlay | null;
+    }
+    return this._overlays.confirmDelete;
+  }
+
+  get confirmDiscardOverlay() {
+    if (!this._overlays.confirmDiscard) {
+      this._enableOverlay = true;
+      flush();
+      this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
+        '#confirmDiscardOverlay'
+      ) as GrOverlay | null;
+    }
+    return this._overlays.confirmDiscard;
+  }
+
+  _computeShowHideIcon(collapsed: boolean) {
+    return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+  }
+
+  _computeShowHideAriaLabel(collapsed: boolean) {
+    return collapsed ? 'Expand' : 'Collapse';
+  }
+
+  @observe('showActions', 'isRobotComment')
+  _calculateActionstoShow(showActions?: boolean, isRobotComment?: boolean) {
+    // Polymer 2: check for undefined
+    if ([showActions, isRobotComment].includes(undefined)) {
+      return;
+    }
+
+    this._showHumanActions = showActions && !isRobotComment;
+    this._showRobotActions = showActions && isRobotComment;
+  }
+
+  @observe('comment')
+  _isRobotComment(comment: UIRobot) {
+    this.isRobotComment = !!comment.robot_id;
+  }
+
+  isOnParent() {
+    return this.side === 'PARENT';
+  }
+
+  _getIsAdmin() {
+    return this.$.restAPI.getIsAdmin();
+  }
+
+  _computeDraftTooltip(unableToSave: boolean) {
+    return unableToSave
+      ? 'Unable to save draft. Please try to save again.'
+      : "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.";
+  }
+
+  _computeDraftText(unableToSave: boolean) {
+    return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
+  }
+
+  save(opt_comment?: UIComment) {
+    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;
+        }
+
+        this._eraseDraftComment();
+        return this.$.restAPI.getResponseObject(response).then(obj => {
+          const resComment = (obj as unknown) as UIDraft;
+          if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
+          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;
+      });
+
+    return this._xhrPromise;
+  }
+
+  _eraseDraftComment() {
+    // Prevents a race condition in which removing the draft comment occurs
+    // prior to it being saved.
+    this.cancelDebouncer('store');
+
+    if (!this.comment?.path) throw new Error('Cannot erase Draft Comment');
+    if (this.changeNum === undefined) {
+      throw new Error('undefined changeNum');
+    }
+    this.$.storage.eraseDraftComment({
+      changeNum: this.changeNum,
+      patchNum: this._getPatchNum(),
+      path: this.comment.path,
+      line: this.comment.line,
+      range: this.comment.range,
+    });
+  }
+
+  _commentChanged(comment: UIComment) {
+    this.editing = !!comment.__editing;
+    this.resolved = !comment.unresolved;
+    if (this.editing) {
+      // It's a new draft/reply, notify.
+      this._fireUpdate();
+    }
+  }
+
+  @observe('comment', 'comments.*')
+  _computeHasHumanReply() {
+    const comment = this.comment;
+    if (!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 === comment.id &&
+        !(c as UIRobot).robot_id
+    );
+  }
+
+  _getEventPayload(): OpenFixPreviewEventDetail {
+    return {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,
+        })
+      );
+    });
+  }
+
+  _computeAccountLabelClass(draft: boolean) {
+    return draft ? 'draft' : '';
+  }
+
+  _draftChanged(draft: boolean) {
+    this.$.container.classList.toggle('draft', draft);
+  }
+
+  _editingChanged(editing?: boolean, previousValue?: boolean) {
+    // 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) {
+      const cancelButton = this.shadowRoot?.querySelector(
+        '.cancel'
+      ) as GrButton | null;
+      if (cancelButton) {
+        cancelButton.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: boolean, draft: boolean) {
+    return isAdmin && !draft ? 'showDeleteButtons' : '';
+  }
+
+  _computeSaveDisabled(
+    draft: string,
+    comment: UIComment | undefined,
+    resolved?: boolean
+  ) {
+    // 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: Event) {
+    if (
+      !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
+    ) {
+      e.preventDefault();
+      this._handleSave(e);
+    }
+  }
+
+  _handleEsc(e: Event) {
+    if (!this._messageText.length) {
+      e.preventDefault();
+      this._handleCancel(e);
+    }
+  }
+
+  _handleToggleCollapsed() {
+    this.collapsed = !this.collapsed;
+  }
+
+  _toggleCollapseClass(collapsed: boolean) {
+    if (collapsed) {
+      this.$.container.classList.add('collapsed');
+    } else {
+      this.$.container.classList.remove('collapsed');
+    }
+  }
+
+  @observe('comment.message')
+  _commentMessageChanged(message: string) {
+    this._messageText = message || '';
+  }
+
+  _messageTextChanged(_: string, oldValue: string) {
+    if (!this.comment || (this.comment && this.comment.id)) {
+      return;
+    }
+
+    const patchNum = this.comment.patch_set
+      ? this.comment.patch_set
+      : this._getPatchNum();
+    const {path, line, range} = this.comment;
+    if (path) {
+      this.debounce(
+        'store',
+        () => {
+          const message = this._messageText;
+          if (this.changeNum === undefined) {
+            throw new Error('undefined changeNum');
+          }
+          const commentLocation: StorageLocation = {
+            changeNum: this.changeNum,
+            patchNum,
+            path,
+            line,
+            range,
+          };
+
+          if ((!message || !message.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
+      );
+    }
+  }
+
+  _handleAnchorClick(e: Event) {
+    e.preventDefault();
+    if (!this.comment) return;
+    this.dispatchEvent(
+      new CustomEvent('comment-anchor-tap', {
+        bubbles: true,
+        composed: true,
+        detail: {
+          number: this.comment.line || FILE,
+          side: this.side,
+        },
+      })
+    );
+  }
+
+  _handleEdit(e: Event) {
+    e.preventDefault();
+    if (this.comment?.message) this._messageText = this.comment.message;
+    this.editing = true;
+    this.reporting.recordDraftInteraction();
+  }
+
+  _handleSave(e: Event) {
+    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: Event) {
+    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: UIComment) {
+    return !comment || !(comment as UIRobot).fix_suggestions;
+  }
+
+  _handleDiscard(e: Event) {
+    e.preventDefault();
+    this.reporting.recordDraftInteraction();
+
+    if (!this._messageText) {
+      this._discardDraft();
+      return;
+    }
+
+    this._openOverlay(this.confirmDiscardOverlay).then(() => {
+      const dialog = this.confirmDiscardOverlay?.querySelector(
+        '#confirmDiscardDialog'
+      ) as GrDialog | null;
+      if (dialog) dialog.resetFocus();
+    });
+  }
+
+  _handleConfirmDiscard(e: Event) {
+    e.preventDefault();
+    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
+    this._closeConfirmDiscardOverlay();
+    return this._discardDraft().then(() => {
+      timer.end();
+    });
+  }
+
+  _discardDraft() {
+    if (!this.comment) return Promise.reject(new Error('undefined comment'));
+    if (!isDraft(this.comment)) {
+      return Promise.reject(new 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 Promise.resolve();
+    }
+
+    this._xhrPromise = this._deleteDraft(this.comment)
+      .then(response => {
+        this.disabled = false;
+        if (!response.ok) {
+          this.discarding = false;
+        }
+
+        this._fireDiscard();
+        return response;
+      })
+      .catch(err => {
+        this.disabled = false;
+        throw err;
+      });
+
+    return this._xhrPromise;
+  }
+
+  _closeConfirmDiscardOverlay() {
+    this._closeOverlay(this.confirmDiscardOverlay);
+  }
+
+  _getSavingMessage(numPending: number, requestFailed?: boolean) {
+    if (requestFailed) {
+      return UNSAVED_MESSAGE;
+    }
+    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');
+    this._updateRequestToast(
+      this._numPendingDraftRequests.number,
+      /* requestFailed=*/ true
+    );
+  }
+
+  _updateRequestToast(numPending: number, requestFailed?: boolean) {
+    const message = this._getSavingMessage(numPending, requestFailed);
+    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
+    );
+  }
+
+  _handleDraftFailure() {
+    this.$.container.classList.add('unableToSave');
+    this._unableToSave = true;
+    this._handleFailedDraftRequest();
+  }
+
+  _saveDraft(draft?: UIComment) {
+    if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
+      throw new Error('undefined draft or changeNum or patchNum');
+    }
+    this._showStartRequest();
+    return this.$.restAPI
+      .saveDiffDraft(this.changeNum, this.patchNum, draft)
+      .then(result => {
+        if (result.ok) {
+          // remove
+          this._unableToSave = false;
+          this.$.container.classList.remove('unableToSave');
+          this._showEndRequest();
+        } else {
+          this._handleDraftFailure();
+        }
+        return result;
+      })
+      .catch(err => {
+        this._handleDraftFailure();
+        throw err;
+      });
+  }
+
+  _deleteDraft(draft: UIComment) {
+    if (this.changeNum === undefined || this.patchNum === undefined) {
+      throw new Error('undefined changeNum or patchNum');
+    }
+    this._showStartRequest();
+    if (!draft.id) throw new Error('Missing id in comment draft.');
+    return this.$.restAPI
+      .deleteDiffDraft(this.changeNum, this.patchNum, {id: draft.id})
+      .then(result => {
+        if (result.ok) {
+          this._showEndRequest();
+        } else {
+          this._handleFailedDraftRequest();
+        }
+        return result;
+      });
+  }
+
+  _getPatchNum(): PatchSetNum {
+    const patchNum = this.isOnParent()
+      ? ('PARENT' as PatchSetNum)
+      : this.patchNum;
+    if (patchNum === undefined) throw new Error('patchNum undefined');
+    return patchNum;
+  }
+
+  @observe('changeNum', 'patchNum', 'comment')
+  _loadLocalDraft(
+    changeNum: number,
+    patchNum?: PatchSetNum,
+    comment?: UIComment
+  ) {
+    // Polymer 2: check for undefined
+    if ([changeNum, patchNum, comment].includes(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 ||
+      !comment.path
+    ) {
+      if (comment) 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();
+    if (!payload.comment) {
+      throw new Error('comment not defined in payload');
+    }
+    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);
+    }
+  }
+
+  _handleCommentDelete() {
+    this._openOverlay(this.confirmDeleteOverlay);
+  }
+
+  _handleCancelDeleteComment() {
+    this._closeOverlay(this.confirmDeleteOverlay);
+  }
+
+  _openOverlay(overlay?: GrOverlay | null) {
+    if (!overlay) {
+      return Promise.reject(new Error('undefined overlay'));
+    }
+    getRootElement().appendChild(overlay);
+    return overlay.open();
+  }
+
+  _computeHideRunDetails(comment: UIRobot, collapsed: boolean) {
+    if (!comment) return true;
+    return !(comment.robot_id && comment.url && !collapsed);
+  }
+
+  _closeOverlay(overlay?: GrOverlay | null) {
+    if (overlay) {
+      getRootElement().removeChild(overlay);
+      overlay.close();
+    }
+  }
+
+  _handleConfirmDeleteComment() {
+    const dialog = this.confirmDeleteOverlay?.querySelector(
+      '#confirmDeleteComment'
+    ) as GrConfirmDeleteCommentDialog | null;
+    if (!dialog || !dialog.message) {
+      throw new Error('missing confirm delete dialog');
+    }
+    if (
+      !this.comment ||
+      !this.comment.id ||
+      this.changeNum === undefined ||
+      this.patchNum === undefined
+    ) {
+      throw new Error('undefined comment or id or changeNum or patchNum');
+    }
+    this.$.restAPI
+      .deleteComment(
+        this.changeNum,
+        this.patchNum,
+        this.comment.id,
+        dialog.message
+      )
+      .then(newComment => {
+        this._handleCancelDeleteComment();
+        this.comment = newComment;
+      });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-comment': 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
deleted file mode 100644
index fc79cba..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
+++ /dev/null
@@ -1,462 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
new file mode 100644
index 0000000..c021461
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -0,0 +1,478 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      font-family: var(--font-family);
+      padding: var(--spacing-m);
+    }
+    :host([collapsed]) {
+      padding: var(--spacing-s) var(--spacing-m);
+    }
+    :host([disabled]) {
+      pointer-events: none;
+    }
+    :host([disabled]) .actions,
+    :host([disabled]) .robotActions,
+    :host([disabled]) .date {
+      opacity: 0.5;
+    }
+    :host([discarding]) {
+      display: none;
+    }
+    .body {
+      padding-top: var(--spacing-m);
+    }
+    .header {
+      align-items: center;
+      cursor: pointer;
+      display: flex;
+    }
+    .headerLeft > span {
+      font-weight: var(--font-weight-bold);
+    }
+    .headerMiddle {
+      color: var(--deemphasized-text-color);
+      flex: 1;
+      overflow: hidden;
+    }
+    .draftLabel,
+    .draftTooltip {
+      color: var(--deemphasized-text-color);
+      display: none;
+    }
+    .date {
+      justify-content: flex-end;
+      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;
+    }
+    .robotActions {
+      /* Better than the negative margin would be to remove the gr-button
+       * padding, but then we would also need to fix the buttons that are
+       * inserted by plugins. :-/ */
+      margin: 4px 0 -4px;
+    }
+    .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):not(.unableToSave) .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);
+    }
+    .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 .body {
+      padding-top: 0;
+    }
+    #container.collapsed .collapsedContent {
+      display: block;
+      overflow: hidden;
+      padding-left: var(--spacing-m);
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    #container.collapsed #deleteBtn,
+    #container.collapsed .date,
+    #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%;
+    }
+    #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;
+    }
+    .patchset-text {
+      color: var(--deemphasized-text-color);
+      margin-left: var(--spacing-s);
+    }
+    .headerLeft gr-account-label {
+      --gr-account-label-text-style: {
+        font-weight: var(--font-weight-bold);
+      }
+      --account-max-length: 120px;
+      width: 120px;
+    }
+    .draft gr-account-label {
+      width: unset;
+    }
+  </style>
+  <div id="container" class="container">
+    <div class="header" id="header" on-click="_handleToggleCollapsed">
+      <div class="headerLeft">
+        <gr-account-label
+          account="[[_getAuthor(comment, _selfAccount)]]"
+          class$="[[_computeAccountLabelClass(draft)]]"
+          hide-status=""
+        >
+        </gr-account-label>
+        <gr-tooltip-content
+          class="draftTooltip"
+          has-tooltip=""
+          title="[[_computeDraftTooltip(_unableToSave)]]"
+          max-width="20em"
+          show-icon=""
+        >
+          <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
+        </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>
+      <gr-button
+        id="deleteBtn"
+        title="Delete Comment"
+        link=""
+        class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
+        hidden$="[[isRobotComment]]"
+        on-click="_handleCommentDelete"
+      >
+        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
+      </gr-button>
+      <template is="dom-if" if="[[showPatchset]]">
+        <span class="patchset-text"> Patchset [[patchNum]]</span>
+      </template>
+      <span class="separator"></span>
+      <template is="dom-if" if="[[comment.updated]]">
+        <span class="date" tabindex="0" on-click="_handleAnchorClick">
+          <gr-date-formatter
+            has-tooltip=""
+            date-str="[[comment.updated]]"
+          ></gr-date-formatter>
+        </span>
+      </template>
+      <div class="show-hide" tabindex="0">
+        <label
+          class="show-hide"
+          aria-label="[[_computeShowHideAriaLabel(collapsed)]]"
+        >
+          <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>
+        <template is="dom-if" if="[[draft]]">
+          <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>
+        </template>
+      </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>
+`;
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
deleted file mode 100644
index 56581d4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
+++ /dev/null
@@ -1,1297 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-comment</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/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-comment></gr-comment>
-  </template>
-</test-fixture>
-
-<test-fixture id="draft">
-  <template>
-    <gr-comment draft="true"></gr-comment>
-  </template>
-</test-fixture>
-
-<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';
-}
-
-suite('gr-comment tests', () => {
-  suite('basic tests', () => {
-    let element;
-    let sandbox;
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-      });
-      element = fixture('basic');
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
-      };
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('collapsible comments', () => {
-      // When a comment (not draft) is loaded, it should be collapsed
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-
-      // The header middle content is only visible when comments are collapsed.
-      // It shows the message in a condensed way, and limits to a single line.
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      // When the header row is clicked, the comment should expand
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-    });
-
-    test('clicking on date link fires event', () => {
-      element.side = 'PARENT';
-      const stub = sinon.stub();
-      element.addEventListener('comment-anchor-tap', stub);
-      const dateEl = element.shadowRoot
-          .querySelector('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail,
-          {side: element.side, number: element.comment.line});
-    });
-
-    test('message is not retrieved from storage when other edits', done => {
-      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-        __otherEditing: true,
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isFalse(storageStub.called);
-        done();
-      });
-    });
-
-    test('message is retrieved from storage when no other edits', done => {
-      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isTrue(storageStub.called);
-        done();
-      });
-    });
-
-    test('_getPatchNum', () => {
-      element.side = 'PARENT';
-      element.patchNum = 1;
-      assert.equal(element._getPatchNum(), 'PARENT');
-      element.side = 'REVISION';
-      assert.equal(element._getPatchNum(), 1);
-    });
-
-    test('comment expand and collapse', () => {
-      element.collapsed = true;
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      element.collapsed = false;
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is is not visible');
-    });
-
-    suite('while editing', () => {
-      setup(() => {
-        element.editing = true;
-        element._messageText = 'test';
-        sandbox.stub(element, '_handleCancel');
-        sandbox.stub(element, '_handleSave');
-        flushAsynchronousOperations();
-      });
-
-      suite('when text is empty', () => {
-        setup(() => {
-          element._messageText = '';
-          element.comment = {};
-        });
-
-        test('esc closes comment when text is empty', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 27); // esc
-          assert.isTrue(element._handleCancel.called);
-        });
-
-        test('ctrl+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'ctrl'); // ctrl + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('meta+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'meta'); // meta + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('ctrl+s does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 83, 'ctrl'); // ctrl + s
-          assert.isFalse(element._handleSave.called);
-        });
-      });
-
-      test('esc does not close comment that has content', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 27); // esc
-        assert.isFalse(element._handleCancel.called);
-      });
-
-      test('ctrl+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'ctrl'); // ctrl + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('meta+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'meta'); // meta + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('ctrl+s saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 83, 'ctrl'); // ctrl + s
-        assert.isTrue(element._handleSave.called);
-      });
-    });
-
-    test('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.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment button for admins with draft is hidden', () => {
-      element._isAdmin = false;
-      element.draft = true;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment', done => {
-      sandbox.stub(
-          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
-      sandbox.spy(element.confirmDeleteOverlay, 'open');
-      element.changeNum = 42;
-      element.patchNum = 0xDEADBEEF;
-      element._isAdmin = true;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.action.delete'));
-      flush(() => {
-        element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
-          const dialog =
-              window.confirmDeleteOverlay
-                  .querySelector('#confirmDeleteComment');
-          dialog.message = 'removal reason';
-          element._handleConfirmDeleteComment();
-          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
-              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
-          done();
-        });
-      });
-    });
-
-    suite('draft update reporting', () => {
-      let endStub;
-      let getTimerStub;
-      let mockEvent;
-
-      setup(() => {
-        mockEvent = {preventDefault() {}};
-        sandbox.stub(element, 'save')
-            .returns(Promise.resolve({}));
-        sandbox.stub(element, '_discardDraft')
-            .returns(Promise.resolve({}));
-        endStub = sinon.stub();
-        getTimerStub = sandbox.stub(element.$.reporting, 'getTimer')
-            .returns({end: endStub});
-      });
-
-      test('create', () => {
-        element.comment = {};
-        return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-        });
-      });
-
-      test('update', () => {
-        element.comment = {id: 'abc_123'};
-        return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-        });
-      });
-
-      test('discard', () => {
-        element.comment = {id: 'abc_123'};
-        sandbox.stub(element, '_closeConfirmDiscardOverlay');
-        return element._handleConfirmDiscard(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-        });
-      });
-    });
-
-    test('edit reports interaction', () => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('discard reports interaction', () => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      element.draft = true;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.discard'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-  });
-
-  suite('gr-comment draft tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-        getConfig() { return Promise.resolve({}); },
-        saveDiffDraft() {
-          return Promise.resolve({
-            ok: true,
-            text() {
-              return Promise.resolve(
-                  ')]}\'\n{' +
-                  '"id": "baf0414d_40572e03",' +
-                  '"path": "/path/to/file",' +
-                  '"line": 5,' +
-                  '"updated": "2015-12-08 21:52:36.177000000",' +
-                  '"message": "saved!"' +
-                '}'
-              );
-            },
-          });
-        },
-        removeChangeReviewer() {
-          return Promise.resolve({ok: true});
-        },
-      });
-      stub('gr-storage', {
-        getDraftComment() { return null; },
-      });
-      element = fixture('draft');
-      element.changeNum = 42;
-      element.patchNum = 1;
-      element.editing = false;
-      element.comment = {
-        __commentSide: 'right',
-        __draft: true,
-        __draftID: 'temp_draft_id',
-        path: '/path/to/file',
-        line: 5,
-      };
-      element.commentSide = 'right';
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('button visibility states', () => {
-      element.showActions = false;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.showActions = true;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.draft = true;
-      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.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.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.shadowRoot
-          .querySelector('.cancel')), 'cancel is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // Delete button is not hidden by default
-      assert.isFalse(element.shadowRoot.querySelector('#deleteBtn').hidden);
-
-      element.isRobotComment = true;
-      element.draft = true;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // It is not expected to see Robot comment drafts, but if they appear,
-      // they will behave the same as non-drafts.
-      element.draft = false;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // A robot comment with run ID should display plain text.
-      element.set(['comment', 'robot_run_id'], 'text');
-      element.editing = false;
-      element.collapsed = false;
-      flushAsynchronousOperations();
-      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.shadowRoot
-          .querySelector('.robotRun.link')).display,
-      'none');
-
-      // Delete button is hidden for robot comments
-      assert.isTrue(element.shadowRoot.querySelector('#deleteBtn').hidden);
-    });
-
-    test('collapsible drafts', () => {
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is is not visible');
-
-      // When the edit button is pressed, should still see the actions
-      // and also textarea
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      flushAsynchronousOperations();
-      assert.isFalse(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-
-      // When toggle again, everything should be hidden except for textarea
-      // and header middle content should be visible
-      MockInteractions.tap(element.$.header);
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-textarea')),
-      'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      // When toggle again, textarea should remain open in the state it was
-      // before
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-    });
-
-    test('robot comment layout', done => {
-      const comment = 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.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.shadowRoot
-          .querySelector('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-      element._messageText = '     ';
-      disabled = element.shadowRoot
-          .querySelector('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-
-      const updateStub = sinon.stub();
-      element.addEventListener('comment-update', updateStub);
-
-      let numDiscardEvents = 0;
-      element.addEventListener('comment-discard', e => {
-        numDiscardEvents++;
-        assert.isFalse(eraseMessageDraftSpy.called);
-        if (numDiscardEvents === 2) {
-          assert.isFalse(updateStub.called);
-          done();
-        }
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.cancel'));
-      element.flushDebouncer('fire-update');
-      element._messageText = '';
-      flushAsynchronousOperations();
-      MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
-    });
-
-    test('draft discard removes message from storage', done => {
-      element._messageText = '';
-      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
-      sandbox.stub(element, '_closeConfirmDiscardOverlay');
-
-      element.addEventListener('comment-discard', e => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        done();
-      });
-      element._handleConfirmDiscard({preventDefault: sinon.stub()});
-    });
-
-    test('storage is cleared only after save success', () => {
-      element._messageText = 'test';
-      const eraseStub = sandbox.stub(element, '_eraseDraftComment');
-      sandbox.stub(element.$.restAPI, 'getResponseObject')
-          .returns(Promise.resolve({}));
-
-      sandbox.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
-
-      const savePromise = element.save();
-      assert.isFalse(eraseStub.called);
-      return savePromise.then(() => {
-        assert.isFalse(eraseStub.called);
-
-        element._saveDraft.restore();
-        sandbox.stub(element, '_saveDraft')
-            .returns(Promise.resolve({ok: true}));
-        return element.save().then(() => {
-          assert.isTrue(eraseStub.called);
-        });
-      });
-    });
-
-    test('_computeSaveDisabled', () => {
-      const comment = {unresolved: true};
-      const msgComment = {message: 'test', unresolved: true};
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-      assert.equal(element._computeSaveDisabled('test', comment, false), false);
-      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-      assert.equal(
-          element._computeSaveDisabled('test', msgComment, false), false);
-      assert.equal(
-          element._computeSaveDisabled('test2', msgComment, false), false);
-      assert.equal(element._computeSaveDisabled('test', comment, true), false);
-      assert.equal(element._computeSaveDisabled('', comment, true), true);
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-    });
-
-    suite('confirm discard', () => {
-      let discardStub;
-      let overlayStub;
-      let mockEvent;
-
-      setup(() => {
-        discardStub = sandbox.stub(element, '_discardDraft');
-        overlayStub = sandbox.stub(element, '_openOverlay')
-            .returns(Promise.resolve());
-        mockEvent = {preventDefault: sinon.stub()};
-      });
-
-      test('confirms discard of comments with message text', () => {
-        element._messageText = 'test';
-        element._handleDiscard(mockEvent);
-        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
-        assert.isFalse(discardStub.called);
-      });
-
-      test('no confirmation for comments without message text', () => {
-        element._messageText = '';
-        element._handleDiscard(mockEvent);
-        assert.isFalse(overlayStub.called);
-        assert.isTrue(discardStub.calledOnce);
-      });
-    });
-
-    test('ctrl+s saves comment', done => {
-      const stub = sinon.stub(element, 'save', () => {
-        assert.isTrue(stub.called);
-        stub.restore();
-        done();
-        return Promise.resolve();
-      });
-      element._messageText = 'is that the horse from horsing around??';
-      element.editing = true;
-      flushAsynchronousOperations();
-      MockInteractions.pressAndReleaseKeyOn(
-          element.textarea.$.textarea.textarea,
-          83, 'ctrl'); // 'ctrl + s'
-    });
-
-    test('draft saving/editing', done => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      const cancelDebounce = sandbox.stub(element, 'cancelDebouncer');
-
-      element.draft = true;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
-      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(dispatchEventStub.calledTwice);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-
-      assert.isTrue(element.disabled,
-          'Element should be disabled when creating draft.');
-
-      element._xhrPromise.then(draft => {
-        assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-save');
-        assert(cancelDebounce.calledWith('store'));
-
-        assert.deepEqual(dispatchEventStub.lastCall.args[0].detail, {
-          comment: {
-            __commentSide: 'right',
-            __draft: true,
-            __draftID: 'temp_draft_id',
-            id: 'baf0414d_40572e03',
-            line: 5,
-            message: 'saved!',
-            path: '/path/to/file',
-            updated: '2015-12-08 21:52:36.177000000',
-          },
-          patchNum: 1,
-        });
-        assert.isFalse(element.disabled,
-            'Element should be enabled when done creating draft.');
-        assert.equal(draft.message, 'saved!');
-        assert.isFalse(element.editing);
-      }).then(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.edit'));
-        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
-            'a world where humans are killed on sight.';
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.save'));
-        assert.isTrue(element.disabled,
-            'Element should be disabled when updating draft.');
-
-        element._xhrPromise.then(draft => {
-          assert.isFalse(element.disabled,
-              'Element should be enabled when done updating draft.');
-          assert.equal(draft.message, 'saved!');
-          assert.isFalse(element.editing);
-          dispatchEventStub.restore();
-          done();
-        });
-      });
-    });
-
-    test('draft prevent save when disabled', () => {
-      const saveStub = sandbox.stub(element, 'save').returns(Promise.resolve());
-      element.showActions = true;
-      element.draft = true;
-      MockInteractions.tap(element.$.header);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
-
-      element.disabled = true;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      assert.isFalse(saveStub.called);
-
-      element.disabled = false;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      assert.isTrue(saveStub.calledOnce);
-    });
-
-    test('proper event fires on resolve, comment is not saved', done => {
-      const save = sandbox.stub(element, 'save');
-      element.addEventListener('comment-update', e => {
-        assert.isTrue(e.detail.comment.unresolved);
-        assert.isFalse(save.called);
-        done();
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.resolve input'));
-    });
-
-    test('resolved comment state indicated by checkbox', () => {
-      sandbox.stub(element, 'save');
-      element.comment = {unresolved: false};
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.shadowRoot
-          .querySelector('.resolve input').checked);
-    });
-
-    test('resolved checkbox saves with tap when !editing', () => {
-      element.editing = false;
-      const save = sandbox.stub(element, 'save');
-
-      element.comment = {unresolved: false};
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      assert.isFalse(save.called);
-      MockInteractions.tap(element.$.resolvedCheckbox);
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      assert.isTrue(save.called);
-    });
-
-    suite('draft saving messages', () => {
-      test('_getSavingMessage', () => {
-        assert.equal(element._getSavingMessage(0), 'All changes saved');
-        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-      });
-
-      test('_show{Start,End}Request', () => {
-        const updateStub = sandbox.stub(element, '_updateRequestToast');
-        element._numPendingDraftRequests.number = 1;
-
-        element._showStartRequest();
-        assert.isTrue(updateStub.calledOnce);
-        assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDraftRequests.number, 2);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledTwice);
-        assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDraftRequests.number, 1);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledThrice);
-        assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDraftRequests.number, 0);
-      });
-    });
-
-    test('cancelling an unsaved draft discards, persists in storage', () => {
-      const discardSpy = sandbox.spy(element, '_fireDiscard');
-      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
-      const eraseStub = sandbox.stub(element.$.storage, 'eraseDraftComment');
-      element._messageText = 'test text';
-      flushAsynchronousOperations();
-      element.flushDebouncer('store');
-
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'test text');
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-      assert.isFalse(eraseStub.called);
-    });
-
-    test('cancelling edit on a saved draft does not store', () => {
-      element.comment.id = 'foo';
-      const discardSpy = sandbox.spy(element, '_fireDiscard');
-      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
-      element._messageText = 'test text';
-      flushAsynchronousOperations();
-      element.flushDebouncer('store');
-
-      assert.isFalse(storeStub.called);
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-    });
-
-    test('deleting text from saved draft and saving deletes the draft', () => {
-      element.comment = {id: 'foo', message: 'test'};
-      element._messageText = '';
-      const discardStub = sandbox.stub(element, '_discardDraft');
-
-      element.save();
-      assert.isTrue(discardStub.called);
-    });
-
-    test('_handleFix fires create-fix event', done => {
-      element.addEventListener('create-fix-comment', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        done();
-      });
-      element.isRobotComment = true;
-      element.comments = [element.comment];
-      flushAsynchronousOperations();
-
-      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-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
new file mode 100644
index 0000000..10925af
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -0,0 +1,1388 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-comment.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+
+const basicFixture = fixtureFromElement('gr-comment');
+
+const draftFixture = fixtureFromTemplate(html`
+<gr-comment draft="true"></gr-comment>
+`);
+
+function isVisible(el) {
+  assert.ok(el);
+  return getComputedStyle(el).getPropertyValue('display') !== 'none';
+}
+
+suite('gr-comment tests', () => {
+  suite('basic tests', () => {
+    let element;
+
+    let openOverlaySpy;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() {
+          return Promise.resolve({
+            email: 'dhruvsri@google.com',
+            name: 'Dhruv Srivastava',
+            _account_id: 1083225,
+            avatars: [{url: 'abc', height: 32}],
+          });
+        },
+      });
+      element = basicFixture.instantiate();
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+      };
+
+      openOverlaySpy = sinon.spy(element, '_openOverlay');
+    });
+
+    teardown(() => {
+      openOverlaySpy.getCalls().forEach(call => {
+        call.args[0].remove();
+      });
+    });
+
+    test('collapsible comments', () => {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+
+      // The header middle content is only visible when comments are collapsed.
+      // It shows the message in a condensed way, and limits to a single line.
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      // When the header row is clicked, the comment should expand
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+    });
+
+    test('clicking on date link fires event', () => {
+      element.side = 'PARENT';
+      const stub = sinon.stub();
+      element.addEventListener('comment-anchor-tap', stub);
+      flush();
+      const dateEl = element.shadowRoot
+          .querySelector('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail,
+          {side: element.side, number: element.comment.line});
+    });
+
+    test('message is not retrieved from storage when other edits', done => {
+      const storageStub = sinon.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+        __otherEditing: true,
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isFalse(storageStub.called);
+        done();
+      });
+    });
+
+    test('message is retrieved from storage when no other edits', done => {
+      const storageStub = sinon.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+        path: 'test',
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isTrue(storageStub.called);
+        done();
+      });
+    });
+
+    test('_getPatchNum', () => {
+      element.side = 'PARENT';
+      element.patchNum = 1;
+      assert.equal(element._getPatchNum(), 'PARENT');
+      element.side = 'REVISION';
+      assert.equal(element._getPatchNum(), 1);
+    });
+
+    test('comment expand and collapse', () => {
+      element.collapsed = true;
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      element.collapsed = false;
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is is not visible');
+    });
+
+    suite('while editing', () => {
+      setup(() => {
+        element.editing = true;
+        element._messageText = 'test';
+        sinon.stub(element, '_handleCancel');
+        sinon.stub(element, '_handleSave');
+        flush();
+      });
+
+      suite('when text is empty', () => {
+        setup(() => {
+          element._messageText = '';
+          element.comment = {};
+        });
+
+        test('esc closes comment when text is empty', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 27); // esc
+          assert.isTrue(element._handleCancel.called);
+        });
+
+        test('ctrl+enter does not save', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 13, 'ctrl'); // ctrl + enter
+          assert.isFalse(element._handleSave.called);
+        });
+
+        test('meta+enter does not save', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 13, 'meta'); // meta + enter
+          assert.isFalse(element._handleSave.called);
+        });
+
+        test('ctrl+s does not save', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 83, 'ctrl'); // ctrl + s
+          assert.isFalse(element._handleSave.called);
+        });
+      });
+
+      test('esc does not close comment that has content', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 27); // esc
+        assert.isFalse(element._handleCancel.called);
+      });
+
+      test('ctrl+enter saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 13, 'ctrl'); // ctrl + enter
+        assert.isTrue(element._handleSave.called);
+      });
+
+      test('meta+enter saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 13, 'meta'); // meta + enter
+        assert.isTrue(element._handleSave.called);
+      });
+
+      test('ctrl+s saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 83, 'ctrl'); // ctrl + s
+        assert.isTrue(element._handleSave.called);
+      });
+    });
+
+    test('delete comment button for non-admins is hidden', () => {
+      element._isAdmin = false;
+      assert.isFalse(element.shadowRoot
+          .querySelector('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment button for admins with draft is hidden', () => {
+      element._isAdmin = false;
+      element.draft = true;
+      assert.isFalse(element.shadowRoot
+          .querySelector('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment', done => {
+      sinon.stub(
+          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
+      sinon.spy(element.confirmDeleteOverlay, 'open');
+      element.changeNum = 42;
+      element.patchNum = 0xDEADBEEF;
+      element._isAdmin = true;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.action.delete')
+          .classList.contains('showDeleteButtons'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.action.delete'));
+      flush(() => {
+        element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
+          const dialog =
+              window.confirmDeleteOverlay
+                  .querySelector('#confirmDeleteComment');
+          dialog.message = 'removal reason';
+          element._handleConfirmDeleteComment();
+          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
+              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
+          done();
+        });
+      });
+    });
+
+    suite('draft update reporting', () => {
+      let endStub;
+      let getTimerStub;
+      let mockEvent;
+
+      setup(() => {
+        mockEvent = {preventDefault() {}};
+        sinon.stub(element, 'save')
+            .returns(Promise.resolve({}));
+        sinon.stub(element, '_discardDraft')
+            .returns(Promise.resolve({}));
+        endStub = sinon.stub();
+        getTimerStub = sinon.stub(element.reporting, 'getTimer')
+            .returns({end: endStub});
+      });
+
+      test('create', () => {
+        element.patchNum = 1;
+        element.comment = {};
+        return element._handleSave(mockEvent).then(() => {
+          assert.equal(element.shadowRoot.querySelector('gr-account-label').
+              shadowRoot.querySelector('span.name').innerText.trim(),
+          'Dhruv Srivastava');
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
+        });
+      });
+
+      test('update', () => {
+        element.comment = {id: 'abc_123'};
+        return element._handleSave(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
+        });
+      });
+
+      test('discard', () => {
+        element.comment = {id: 'abc_123'};
+        sinon.stub(element, '_closeConfirmDiscardOverlay');
+        return element._handleConfirmDiscard(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
+        });
+      });
+    });
+
+    test('edit reports interaction', () => {
+      const reportStub = sinon.stub(element.reporting,
+          'recordDraftInteraction');
+      element.draft = true;
+      flush();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('discard reports interaction', () => {
+      const reportStub = sinon.stub(element.reporting,
+          'recordDraftInteraction');
+      element.draft = true;
+      flush();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.discard'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('failed save draft request', done => {
+      element.draft = true;
+      element.changeNum = 1;
+      element.patchNum = 1;
+      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
+      const diffDraftStub =
+        sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
+            Promise.resolve({ok: false}));
+      element._saveDraft({id: 'abc_123'});
+      flush(() => {
+        let args = updateRequestStub.lastCall.args;
+        assert.deepEqual(args, [0, true]);
+        assert.equal(element._getSavingMessage(...args),
+            __testOnly_UNSAVED_MESSAGE);
+        assert.equal(element.shadowRoot.querySelector('.draftLabel').innerText,
+            'DRAFT(Failed to save)');
+        assert.isTrue(isVisible(element.shadowRoot
+            .querySelector('.save')), 'save is visible');
+        diffDraftStub.returns(
+            Promise.resolve({ok: true}));
+        element._saveDraft({id: 'abc_123'});
+        flush(() => {
+          args = updateRequestStub.lastCall.args;
+          assert.deepEqual(args, [0]);
+          assert.equal(element._getSavingMessage(...args),
+              'All changes saved');
+          assert.equal(element.shadowRoot.querySelector('.draftLabel')
+              .innerText, 'DRAFT');
+          assert.isFalse(isVisible(element.shadowRoot
+              .querySelector('.save')), 'save is not visible');
+          assert.isFalse(element._unableToSave);
+          done();
+        });
+      });
+    });
+
+    test('failed save draft request with promise failure', done => {
+      element.draft = true;
+      element.changeNum = 1;
+      element.patchNum = 1;
+      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
+      const diffDraftStub =
+        sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
+            Promise.reject(new Error()));
+      element._saveDraft({id: 'abc_123'});
+      flush(() => {
+        let args = updateRequestStub.lastCall.args;
+        assert.deepEqual(args, [0, true]);
+        assert.equal(element._getSavingMessage(...args),
+            __testOnly_UNSAVED_MESSAGE);
+        assert.equal(element.shadowRoot.querySelector('.draftLabel').innerText,
+            'DRAFT(Failed to save)');
+        assert.isTrue(isVisible(element.shadowRoot
+            .querySelector('.save')), 'save is visible');
+        diffDraftStub.returns(
+            Promise.resolve({ok: true}));
+        element._saveDraft({id: 'abc_123'});
+        flush(() => {
+          args = updateRequestStub.lastCall.args;
+          assert.deepEqual(args, [0]);
+          assert.equal(element._getSavingMessage(...args),
+              'All changes saved');
+          assert.equal(element.shadowRoot.querySelector('.draftLabel')
+              .innerText, 'DRAFT');
+          assert.isFalse(isVisible(element.shadowRoot
+              .querySelector('.save')), 'save is not visible');
+          assert.isFalse(element._unableToSave);
+          done();
+        });
+      });
+    });
+  });
+
+  suite('gr-comment draft tests', () => {
+    let element;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+        getConfig() { return Promise.resolve({}); },
+        saveDiffDraft() {
+          return Promise.resolve({
+            ok: true,
+            text() {
+              return Promise.resolve(
+                  ')]}\'\n{' +
+                  '"id": "baf0414d_40572e03",' +
+                  '"path": "/path/to/file",' +
+                  '"line": 5,' +
+                  '"updated": "2015-12-08 21:52:36.177000000",' +
+                  '"message": "saved!"' +
+                '}'
+              );
+            },
+          });
+        },
+        removeChangeReviewer() {
+          return Promise.resolve({ok: true});
+        },
+      });
+      stub('gr-storage', {
+        getDraftComment() { return null; },
+      });
+      element = draftFixture.instantiate();
+      element.changeNum = 42;
+      element.patchNum = 1;
+      element.editing = false;
+      element.comment = {
+        __commentSide: 'right',
+        __draft: true,
+        __draftID: 'temp_draft_id',
+        path: '/path/to/file',
+        line: 5,
+      };
+      element.commentSide = 'right';
+    });
+
+    test('button visibility states', () => {
+      element.showActions = false;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.showActions = true;
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.draft = true;
+      flush();
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.discard')), 'discard is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.resolve')), 'resolve is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.editing = true;
+      flush();
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.discard')), 'discard not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.resolve')), 'resolve is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.draft = false;
+      element.editing = false;
+      flush();
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.discard')),
+      'discard is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is not visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.comment.id = 'foo';
+      element.draft = true;
+      element.editing = true;
+      flush();
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // Delete button is not hidden by default
+      assert.isFalse(element.shadowRoot.querySelector('#deleteBtn').hidden);
+
+      element.isRobotComment = true;
+      element.draft = true;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // It is not expected to see Robot comment drafts, but if they appear,
+      // they will behave the same as non-drafts.
+      element.draft = false;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // A robot comment with run ID should display plain text.
+      element.set(['comment', 'robot_run_id'], 'text');
+      element.editing = false;
+      element.collapsed = false;
+      flush();
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotRun.link').textContent === 'Run Details');
+
+      // A robot comment with run ID and url should display a link.
+      element.set(['comment', 'url'], '/path/to/run');
+      flush();
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.robotRun.link')).display,
+      'none');
+
+      // Delete button is hidden for robot comments
+      assert.isTrue(element.shadowRoot.querySelector('#deleteBtn').hidden);
+    });
+
+    test('collapsible drafts', () => {
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is is not visible');
+
+      // When the edit button is pressed, should still see the actions
+      // and also textarea
+      element.draft = true;
+      flush();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      flush();
+      assert.isFalse(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+
+      // When toggle again, everything should be hidden except for textarea
+      // and header middle content should be visible
+      MockInteractions.tap(element.$.header);
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-textarea')),
+      'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      // When toggle again, textarea should remain open in the state it was
+      // before
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+    });
+
+    test('robot comment layout', done => {
+      const comment = {robot_id: 'happy_robot_id',
+        url: '/robot/comment',
+        author: {
+          name: 'Happy Robot',
+          display_name: 'Display name Robot',
+        }, ...element.comment};
+      element.comment = comment;
+      element.collapsed = false;
+      flush(() => {
+        let runIdMessage;
+        runIdMessage = element.shadowRoot
+            .querySelector('.runIdMessage');
+        assert.isFalse(runIdMessage.hidden);
+
+        const runDetailsLink = element.shadowRoot
+            .querySelector('.robotRunLink');
+        assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
+
+        const robotServiceName = element.shadowRoot
+            .querySelector('gr-account-label')
+            .shadowRoot.querySelector('span.name');
+        assert.equal(robotServiceName.textContent.trim(), 'Display name Robot');
+
+        const authorName = element.shadowRoot
+            .querySelector('.robotId');
+        assert.isTrue(authorName.innerText === 'Happy Robot');
+
+        element.collapsed = true;
+        flush();
+        runIdMessage = element.shadowRoot
+            .querySelector('.runIdMessage');
+        assert.isTrue(runIdMessage.hidden);
+        done();
+      });
+    });
+
+    test('author name fallback to email', done => {
+      const comment = {url: '/robot/comment',
+        author: {
+          email: 'test@test.com',
+        }, ...element.comment};
+      element.comment = comment;
+      element.collapsed = false;
+      flush(() => {
+        const authorName = element.shadowRoot
+            .querySelector('gr-account-label')
+            .shadowRoot.querySelector('span.name');
+        assert.equal(authorName.innerText.trim(), 'test@test.com');
+        done();
+      });
+    });
+
+    test('patchset level comment', done => {
+      const comment = {...element.comment,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, line: undefined,
+        range: undefined};
+      element.comment = comment;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      assert.isTrue(element.editing);
+
+      element._messageText = 'hello world';
+      const eraseMessageDraftSpy = sinon.spy(element.$.storage,
+          'eraseDraftComment');
+      const mockEvent = {preventDefault: sinon.stub()};
+      element._handleSave(mockEvent);
+      flush(() => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+    });
+
+    test('draft creation/cancellation', done => {
+      assert.isFalse(element.editing);
+      element.draft = true;
+      flush();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      assert.isTrue(element.editing);
+
+      element._messageText = '';
+      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
+
+      // Save should be disabled on an empty message.
+      let disabled = element.shadowRoot
+          .querySelector('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+      element._messageText = '     ';
+      disabled = element.shadowRoot
+          .querySelector('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+
+      const updateStub = sinon.stub();
+      element.addEventListener('comment-update', updateStub);
+
+      let numDiscardEvents = 0;
+      element.addEventListener('comment-discard', e => {
+        numDiscardEvents++;
+        assert.isFalse(eraseMessageDraftSpy.called);
+        if (numDiscardEvents === 2) {
+          assert.isFalse(updateStub.called);
+          done();
+        }
+      });
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.cancel'));
+      element.flushDebouncer('fire-update');
+      element._messageText = '';
+      flush();
+      MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
+    });
+
+    test('draft discard removes message from storage', done => {
+      element._messageText = '';
+      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
+      sinon.stub(element, '_closeConfirmDiscardOverlay');
+
+      element.addEventListener('comment-discard', e => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+      element._handleConfirmDiscard({preventDefault: sinon.stub()});
+    });
+
+    test('storage is cleared only after save success', () => {
+      element._messageText = 'test';
+      const eraseStub = sinon.stub(element, '_eraseDraftComment');
+      sinon.stub(element.$.restAPI, 'getResponseObject')
+          .returns(Promise.resolve({}));
+
+      sinon.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
+
+      const savePromise = element.save();
+      assert.isFalse(eraseStub.called);
+      return savePromise.then(() => {
+        assert.isFalse(eraseStub.called);
+
+        element._saveDraft.restore();
+        sinon.stub(element, '_saveDraft')
+            .returns(Promise.resolve({ok: true}));
+        return element.save().then(() => {
+          assert.isTrue(eraseStub.called);
+        });
+      });
+    });
+
+    test('_computeSaveDisabled', () => {
+      const comment = {unresolved: true};
+      const msgComment = {message: 'test', unresolved: true};
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      assert.equal(element._computeSaveDisabled('test', comment, false), false);
+      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
+      assert.equal(
+          element._computeSaveDisabled('test', msgComment, false), false);
+      assert.equal(
+          element._computeSaveDisabled('test2', msgComment, false), false);
+      assert.equal(element._computeSaveDisabled('test', comment, true), false);
+      assert.equal(element._computeSaveDisabled('', comment, true), true);
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+    });
+
+    suite('confirm discard', () => {
+      let discardStub;
+      let overlayStub;
+      let mockEvent;
+
+      setup(() => {
+        discardStub = sinon.stub(element, '_discardDraft');
+        overlayStub = sinon.stub(element, '_openOverlay')
+            .returns(Promise.resolve());
+        mockEvent = {preventDefault: sinon.stub()};
+      });
+
+      test('confirms discard of comments with message text', () => {
+        element._messageText = 'test';
+        element._handleDiscard(mockEvent);
+        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
+        assert.isFalse(discardStub.called);
+      });
+
+      test('no confirmation for comments without message text', () => {
+        element._messageText = '';
+        element._handleDiscard(mockEvent);
+        assert.isFalse(overlayStub.called);
+        assert.isTrue(discardStub.calledOnce);
+      });
+    });
+
+    test('ctrl+s saves comment', done => {
+      const stub = sinon.stub(element, 'save').callsFake(() => {
+        assert.isTrue(stub.called);
+        stub.restore();
+        done();
+        return Promise.resolve();
+      });
+      element._messageText = 'is that the horse from horsing around??';
+      element.editing = true;
+      flush();
+      MockInteractions.pressAndReleaseKeyOn(
+          element.textarea.$.textarea.textarea,
+          83, 'ctrl'); // 'ctrl + s'
+    });
+
+    test('draft saving/editing', done => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      const cancelDebounce = sinon.stub(element, 'cancelDebouncer');
+
+      element.draft = true;
+      flush();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+      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(dispatchEventStub.calledTwice);
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+
+      assert.isTrue(element.disabled,
+          'Element should be disabled when creating draft.');
+
+      element._xhrPromise.then(draft => {
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-save');
+        assert(cancelDebounce.calledWith('store'));
+
+        assert.deepEqual(dispatchEventStub.lastCall.args[0].detail, {
+          comment: {
+            __commentSide: 'right',
+            __draft: true,
+            __draftID: 'temp_draft_id',
+            id: 'baf0414d_40572e03',
+            line: 5,
+            message: 'saved!',
+            path: '/path/to/file',
+            updated: '2015-12-08 21:52:36.177000000',
+          },
+          patchNum: 1,
+        });
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done creating draft.');
+        assert.equal(draft.message, 'saved!');
+        assert.isFalse(element.editing);
+      }).then(() => {
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('.edit'));
+        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
+            'a world where humans are killed on sight.';
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('.save'));
+        assert.isTrue(element.disabled,
+            'Element should be disabled when updating draft.');
+
+        element._xhrPromise.then(draft => {
+          assert.isFalse(element.disabled,
+              'Element should be enabled when done updating draft.');
+          assert.equal(draft.message, 'saved!');
+          assert.isFalse(element.editing);
+          dispatchEventStub.restore();
+          done();
+        });
+      });
+    });
+
+    test('draft prevent save when disabled', () => {
+      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
+      element.showActions = true;
+      element.draft = true;
+      flush();
+      MockInteractions.tap(element.$.header);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+
+      element.disabled = true;
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      assert.isFalse(saveStub.called);
+
+      element.disabled = false;
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      assert.isTrue(saveStub.calledOnce);
+    });
+
+    test('proper event fires on resolve, comment is not saved', done => {
+      const save = sinon.stub(element, 'save');
+      element.addEventListener('comment-update', e => {
+        assert.isTrue(e.detail.comment.unresolved);
+        assert.isFalse(save.called);
+        done();
+      });
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.resolve input'));
+    });
+
+    test('resolved comment state indicated by checkbox', () => {
+      sinon.stub(element, 'save');
+      element.comment = {unresolved: false};
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.shadowRoot
+          .querySelector('.resolve input').checked);
+    });
+
+    test('resolved checkbox saves with tap when !editing', () => {
+      element.editing = false;
+      const save = sinon.stub(element, 'save');
+
+      element.comment = {unresolved: false};
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      assert.isFalse(save.called);
+      MockInteractions.tap(element.$.resolvedCheckbox);
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      assert.isTrue(save.called);
+    });
+
+    suite('draft saving messages', () => {
+      test('_getSavingMessage', () => {
+        assert.equal(element._getSavingMessage(0), 'All changes saved');
+        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
+        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
+        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
+      });
+
+      test('_show{Start,End}Request', () => {
+        const updateStub = sinon.stub(element, '_updateRequestToast');
+        element._numPendingDraftRequests.number = 1;
+
+        element._showStartRequest();
+        assert.isTrue(updateStub.calledOnce);
+        assert.equal(updateStub.lastCall.args[0], 2);
+        assert.equal(element._numPendingDraftRequests.number, 2);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledTwice);
+        assert.equal(updateStub.lastCall.args[0], 1);
+        assert.equal(element._numPendingDraftRequests.number, 1);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledThrice);
+        assert.equal(updateStub.lastCall.args[0], 0);
+        assert.equal(element._numPendingDraftRequests.number, 0);
+      });
+    });
+
+    test('cancelling an unsaved draft discards, persists in storage', () => {
+      const discardSpy = sinon.spy(element, '_fireDiscard');
+      const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
+      const eraseStub = sinon.stub(element.$.storage, 'eraseDraftComment');
+      element._messageText = 'test text';
+      flush();
+      element.flushDebouncer('store');
+
+      assert.isTrue(storeStub.called);
+      assert.equal(storeStub.lastCall.args[1], 'test text');
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+      assert.isFalse(eraseStub.called);
+    });
+
+    test('cancelling edit on a saved draft does not store', () => {
+      element.comment.id = 'foo';
+      const discardSpy = sinon.spy(element, '_fireDiscard');
+      const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
+      element._messageText = 'test text';
+      flush();
+      element.flushDebouncer('store');
+
+      assert.isFalse(storeStub.called);
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+    });
+
+    test('deleting text from saved draft and saving deletes the draft', () => {
+      element.comment = {id: 'foo', message: 'test'};
+      element._messageText = '';
+      const discardStub = sinon.stub(element, '_discardDraft');
+
+      element.save();
+      assert.isTrue(discardStub.called);
+    });
+
+    test('_handleFix fires create-fix event', done => {
+      element.addEventListener('create-fix-comment', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.isRobotComment = true;
+      element.comments = [element.comment];
+      flush();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.fix'));
+    });
+
+    test('do not show Please Fix button if human reply exists', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __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];
+      flush();
+      assert.isNull(element.shadowRoot
+          .querySelector('robotActions gr-button'));
+    });
+
+    test('show Please Fix if no human reply', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __commentSide: 'right',
+          collapsed: false,
+        },
+      ];
+      element.comment = element.comments[0];
+      flush();
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.robotActions gr-button'));
+    });
+
+    test('_handleShowFix fires open-fix-preview event', done => {
+      element.addEventListener('open-fix-preview', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.comment = {fix_suggestions: [{}]};
+      element.isRobotComment = true;
+      flush();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.show-fix'));
+    });
+  });
+
+  suite('respectful tips', () => {
+    let element;
+
+    let clock;
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+      });
+      clock = sinon.useFakeTimers();
+    });
+
+    teardown(() => {
+      clock.restore();
+      sinon.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 = draftFixture.instantiate();
+      // 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 = draftFixture.instantiate();
+      // 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'));
+        flush();
+        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 = draftFixture.instantiate();
+      // 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 = draftFixture.instantiate();
+      // 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 = draftFixture.instantiate();
+      // 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();
+      });
+    });
+  });
+});
+
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
deleted file mode 100644
index b0f387b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
+++ /dev/null
@@ -1,76 +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.
- */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrConfirmDeleteCommentDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-confirm-delete-comment-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,
-    };
-  }
-
-  resetFocus() {
-    this.$.messageInput.textarea.focus();
-  }
-
-  _handleConfirmTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('confirm', {
-      detail: {reason: this.message},
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _handleCancelTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('cancel', {
-      composed: true, 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.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
new file mode 100644
index 0000000..a636a07
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -0,0 +1,89 @@
+/**
+ * @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 '../gr-dialog/gr-dialog';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-delete-comment-dialog_html';
+import {property, customElement} from '@polymer/decorators';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-delete-comment-dialog': GrConfirmDeleteCommentDialog;
+  }
+}
+export interface GrConfirmDeleteCommentDialog {
+  $: {
+    messageInput: IronAutogrowTextareaElement;
+  };
+}
+
+@customElement('gr-confirm-delete-comment-dialog')
+export class GrConfirmDeleteCommentDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  static get is() {
+    return 'gr-confirm-delete-comment-dialog';
+  }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: String})
+  message?: string;
+
+  resetFocus() {
+    this.$.messageInput.textarea.focus();
+  }
+
+  _handleConfirmTap(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('confirm', {
+        detail: {reason: this.message},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCancelTap(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+}
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
deleted file mode 100644
index 2d0fa6f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
new file mode 100644
index 0000000..6876c1a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
@@ -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';
+
+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.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
deleted file mode 100644
index 0f6168e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const COPY_TIMEOUT_MS = 1000;
-
-/** @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: {
-        type: Boolean,
-        value: false,
-      },
-      hideInput: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  focusOnCopy() {
-    this.$.button.focus();
-  }
-
-  _computeInputClass(hideInput) {
-    return hideInput ? 'hideInput' : '';
-  }
-
-  _handleInputClick(e) {
-    e.preventDefault();
-    dom(e).rootTarget.select();
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
new file mode 100644
index 0000000..47dacc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -0,0 +1,95 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-copy-clipboard_html';
+import {GrButton} from '../gr-button/gr-button';
+import {customElement, property} from '@polymer/decorators';
+import {IronIconElement} from '@polymer/iron-icon';
+
+const COPY_TIMEOUT_MS = 1000;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-copy-clipboard': GrCopyClipboard;
+  }
+}
+
+export interface GrCopyClipboard {
+  $: {button: GrButton; icon: IronIconElement; input: HTMLInputElement};
+}
+
+/** @extends PolymerElement */
+@customElement('gr-copy-clipboard')
+export class GrCopyClipboard extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  text: string | undefined;
+
+  @property({type: String})
+  buttonTitle: string | undefined;
+
+  @property({type: Boolean})
+  hasTooltip = false;
+
+  @property({type: Boolean})
+  hideInput = false;
+
+  focusOnCopy() {
+    this.$.button.focus();
+  }
+
+  _computeInputClass(hideInput: boolean) {
+    return hideInput ? 'hideInput' : '';
+  }
+
+  _handleInputClick(e: MouseEvent) {
+    e.preventDefault();
+    ((dom(e) as EventApi).rootTarget as HTMLInputElement).select();
+  }
+
+  _copyToClipboard(e: MouseEvent) {
+    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
+    );
+  }
+}
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
deleted file mode 100644
index 8378de5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
new file mode 100644
index 0000000..197c94c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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"
+      aria-label="Click to copy to clipboard"
+    >
+      <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
deleted file mode 100644
index 398f7f0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
+++ /dev/null
@@ -1,105 +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">
-<meta charset="utf-8">
-<title>gr-copy-clipboard</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-copy-clipboard></gr-copy-clipboard>
-  </template>
-</test-fixture>
-
-<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.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-copy-clipboard/gr-copy-clipboard_test.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
new file mode 100644
index 0000000..55b2483
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.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 '../../../test/common-test-setup-karma.js';
+import './gr-copy-clipboard.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-copy-clipboard');
+
+suite('gr-copy-clipboard tests', () => {
+  let element;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+    await flush();
+  });
+
+  test('copy to clipboard', () => {
+    const clipboardSpy = sinon.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;
+    flush();
+    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);
+  });
+});
+
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
deleted file mode 100644
index 1c3a689..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
+++ /dev/null
@@ -1,56 +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.
- */
-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.ts b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
new file mode 100644
index 0000000..bbbce16
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
@@ -0,0 +1,44 @@
+/**
+ * @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.
+   */
+  computePluralString(count: number, noun: string): string {
+    return this.computeString(count, noun) + (count > 1 ? 's' : '');
+  },
+
+  /**
+   * Returns a count plus string that is not pluralized.
+   */
+  computeString(count: number, noun: string): string {
+    if (count === 0) {
+      return '';
+    }
+    return `${count} ${noun}`;
+  },
+
+  /**
+   * Returns a count plus arbitrary text.
+   */
+  computeShortString(count: number, text: string): string {
+    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
deleted file mode 100644
index 63435d2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
+++ /dev/null
@@ -1,58 +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">
-<meta charset="utf-8">
-<title>gr-count-string-formatter</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 {GrCountStringFormatter} from './gr-count-string-formatter.js';
-
-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-count-string-formatter/gr-count-string-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
new file mode 100644
index 0000000..36637ec
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
@@ -0,0 +1,47 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import {GrCountStringFormatter} from './gr-count-string-formatter.js';
+
+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');
+  });
+});
+
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
deleted file mode 100644
index 222109e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ /dev/null
@@ -1,443 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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',
-};
-
-/** @extends Polymer.Element */
-class GrCursorManager extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-cursor-manager'; }
-
-  static get properties() {
-    return {
-      stops: {
-        type: Array,
-        value() {
-          return [];
-        },
-        observer: '_updateIndex',
-      },
-      /**
-       * @type {?Object}
-       */
-      target: {
-        type: Object,
-        notify: true,
-        observer: '_scrollToTarget',
-      },
-      /**
-       * The height of content intended to be included with the target.
-       *
-       * @type {?number}
-       */
-      _targetHeight: Number,
-
-      /**
-       * The index of the current target (if any). -1 otherwise.
-       */
-      index: {
-        type: Number,
-        value: -1,
-        notify: true,
-      },
-
-      /**
-       * The class to apply to the current target. Use null for no class.
-       */
-      cursorTargetClass: {
-        type: String,
-        value: null,
-      },
-
-      /**
-       * The scroll behavior for the cursor. Values are 'never' and
-       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
-       * the viewport.
-       * TODO (beckysiegel) figure out why it can be undefined
-       *
-       * @type {string|undefined}
-       */
-      scrollBehavior: {
-        type: String,
-        value: ScrollBehavior.NEVER,
-      },
-
-      /**
-       * When true, will call element.focus() during scrolling.
-       */
-      focusOnMove: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * 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,
-      },
-    };
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unsetCursor();
-  }
-
-  /**
-   * 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
-   */
-
-  next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
-    this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
-  }
-
-  previous(opt_condition) {
-    this._moveCursor(-1, opt_condition);
-  }
-
-  /**
-   * 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);
-
-    let closestToTheCenter = null;
-    let minDistanceToCenter = null;
-    let unobservedCount = filteredStops.length;
-
-    const observer = new IntersectionObserver(entries => {
-      // This callback is called for the first time immediately.
-      // Typically it gets all observed stops at once, but
-      // sometimes can get them in several chunks.
-      entries.forEach(entry => {
-        observer.unobserve(entry.target);
-
-        // In Edge it is recommended to use intersectionRatio instead of
-        // isIntersecting.
-        const isInsideViewport =
-            entry.isIntersecting || entry.intersectionRatio > 0;
-        if (!isInsideViewport) {
-          return;
-        }
-        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);
-    });
-  }
-
-  _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;
-  }
-
-  /**
-   * 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.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
new file mode 100644
index 0000000..9fdbb34
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -0,0 +1,477 @@
+/**
+ * @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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-cursor-manager_html';
+import {ScrollMode} from '../../../constants/constants';
+import {customElement, property, observe} from '@polymer/decorators';
+
+export interface GrCursorManager {
+  $: {};
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-cursor-manager': GrCursorManager;
+  }
+}
+
+/**
+ * Return type for cursor moves, that indicate whether a move was possible.
+ */
+export enum CursorMoveResult {
+  /** The cursor was successfully moved. */
+  MOVED,
+  /** There were no stops - the cursor was reset. */
+  NO_STOPS,
+  /**
+   * There was no more matching stop to move to - the cursor was clipped to the
+   * end.
+   */
+  CLIPPED,
+  /** The abort condition would have been fulfilled for the new target. */
+  ABORTED,
+}
+
+/** A sentinel that can be inserted to disallow moving across. */
+export class AbortStop {}
+
+export type Stop = HTMLElement | AbortStop;
+
+/**
+ * Type guard and checker to check if a stop can be targetted.
+ * Abort stops cannot be targetted.
+ */
+export function isTargetable(stop: Stop): stop is HTMLElement {
+  return !(stop instanceof AbortStop);
+}
+
+@customElement('gr-cursor-manager')
+export class GrCursorManager extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object, notify: true})
+  target: HTMLElement | null = null;
+
+  /**
+   * The height of content intended to be included with the target.
+   */
+  @property({type: Number})
+  _targetHeight: number | null = null;
+
+  /**
+   * The index of the current target (if any). -1 otherwise.
+   */
+  @property({type: Number, notify: true})
+  index = -1;
+
+  /**
+   * The class to apply to the current target. Use null for no class.
+   */
+  @property({type: String})
+  cursorTargetClass: string | null = null;
+
+  /**
+   * The scroll behavior for the cursor. Values are 'never' and
+   * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+   * the viewport.
+   * TODO (beckysiegel) figure out why it can be undefined
+   *
+   * @type {string|undefined}
+   */
+  @property({type: String})
+  scrollMode: string = ScrollMode.NEVER;
+
+  /**
+   * When true, will call element.focus() during scrolling.
+   */
+  @property({type: Boolean})
+  focusOnMove = false;
+
+  @property({type: Array})
+  stops: Stop[] = [];
+
+  /** Only non-AbortStop stops. */
+  get targetableStops(): HTMLElement[] {
+    return this.stops.filter(isTargetable);
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unsetCursor();
+  }
+
+  /**
+   * Move the cursor forward. Clipped to the ends of the stop list.
+   *
+   * @param options.filter Will keep going and skip any stops for which this
+   *    condition is not met.
+   * @param options.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 options.clipToTop When none of the next indices match, move
+   *     back to first instead of to last.
+   * @return If a move was performed or why not.
+   * @private
+   */
+  next(
+    options: {
+      filter?: (stop: HTMLElement) => boolean;
+      getTargetHeight?: (target: HTMLElement) => number;
+      clipToTop?: boolean;
+    } = {}
+  ): CursorMoveResult {
+    return this._moveCursor(1, options);
+  }
+
+  previous(
+    options: {
+      filter?: (stop: HTMLElement) => boolean;
+    } = {}
+  ): CursorMoveResult {
+    return this._moveCursor(-1, options);
+  }
+
+  /**
+   * 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 condition Optional condition. If a condition
+   * is passed only stops which meet conditions are taken into account.
+   */
+  moveToVisibleArea(condition?: (el: Element) => boolean) {
+    if (!this.stops || !this._isIntersectionObserverSupported()) {
+      return;
+    }
+    const filteredStops = condition
+      ? this.targetableStops.filter(condition)
+      : this.targetableStops;
+    const dims = this._getWindowDims();
+    const windowCenter = Math.round(dims.innerHeight / 2);
+
+    let closestToTheCenter: HTMLElement | null = null;
+    let minDistanceToCenter: number | null = null;
+    let unobservedCount = filteredStops.length;
+
+    const observer = new IntersectionObserver(entries => {
+      // This callback is called for the first time immediately.
+      // Typically it gets all observed stops at once, but
+      // sometimes can get them in several chunks.
+      entries.forEach(entry => {
+        observer.unobserve(entry.target);
+
+        // In Edge it is recommended to use intersectionRatio instead of
+        // isIntersecting.
+        const isInsideViewport =
+          entry.isIntersecting || entry.intersectionRatio > 0;
+        if (!isInsideViewport) {
+          return;
+        }
+        const center =
+          entry.boundingClientRect.top +
+          Math.round(entry.boundingClientRect.height / 2);
+        const distanceToWindowCenter = Math.abs(center - windowCenter);
+        if (
+          minDistanceToCenter === null ||
+          distanceToWindowCenter < minDistanceToCenter
+        ) {
+          // entry.target comes from the filteredStops array,
+          // hence it is an HTMLElement
+          closestToTheCenter = entry.target as HTMLElement;
+          minDistanceToCenter = distanceToWindowCenter;
+        }
+      });
+      unobservedCount -= entries.length;
+      if (unobservedCount === 0 && 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);
+    });
+  }
+
+  _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;
+  }
+
+  /**
+   * Set the cursor to an arbitrary stop - if the given element is not one of
+   * the stops, unset the cursor.
+   *
+   * @param noScroll prevent any potential scrolling in response
+   * setting the cursor.
+   */
+  setCursor(element: HTMLElement, noScroll?: boolean) {
+    if (!this.targetableStops.includes(element)) {
+      this.unsetCursor();
+      return;
+    }
+    let behavior;
+    if (noScroll) {
+      behavior = this.scrollMode;
+      this.scrollMode = ScrollMode.NEVER;
+    }
+
+    this.unsetCursor();
+    this.target = element;
+    this._updateIndex();
+    this._decorateTarget();
+
+    if (noScroll && behavior) {
+      this.scrollMode = behavior;
+    }
+  }
+
+  unsetCursor() {
+    this._unDecorateTarget();
+    this.index = -1;
+    this.target = null;
+    this._targetHeight = null;
+  }
+
+  isAtStart() {
+    return this.index === 0;
+  }
+
+  isAtEnd() {
+    // Unset cursor should not be considered "at end", even when there are no
+    // cursor stops.
+    return this.index !== -1 && this.index === this.stops.length - 1;
+  }
+
+  moveToStart() {
+    if (this.stops.length) {
+      this.setCursorAtIndex(0);
+    }
+  }
+
+  moveToEnd() {
+    if (this.stops.length) {
+      this.setCursorAtIndex(this.stops.length - 1);
+    }
+  }
+
+  setCursorAtIndex(index: number, noScroll?: boolean) {
+    const stop = this.stops[index];
+    if (isTargetable(stop)) {
+      this.setCursor(stop, noScroll);
+    }
+  }
+
+  /**
+   * Move the cursor forward or backward by delta. Clipped to the beginning or
+   * end of stop list.
+   *
+   * @param delta either -1 or 1.
+   * @param options.abort Will abort moving the cursor when encountering a
+   *    stop for which this condition is met. Will abort even if the stop
+   *    would have been filtered
+   * @param options.filter Will keep going and skip any stops for which this
+   *    condition is not met.
+   * @param options.getTargetHeight Optional function to calculate the
+   * height of the target's 'section'. The height of the target itself is
+   * sometimes different, used by the diff cursor.
+   * @param options.clipToTop When none of the next indices match, move
+   * back to first instead of to last.
+   * @return  If a move was performed or why not.
+   * @private
+   */
+  _moveCursor(
+    delta: number,
+    {
+      filter,
+      getTargetHeight,
+      clipToTop,
+    }: {
+      filter?: (stop: HTMLElement) => boolean;
+      getTargetHeight?: (target: HTMLElement) => number;
+      clipToTop?: boolean;
+    } = {}
+  ): CursorMoveResult {
+    if (!this.stops.length) {
+      this.unsetCursor();
+      return CursorMoveResult.NO_STOPS;
+    }
+
+    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;
+    }
+
+    let clipped = false;
+    let newStop: Stop;
+    do {
+      newIndex += delta;
+      if (
+        (delta > 0 && newIndex >= this.stops.length) ||
+        (delta < 0 && newIndex < 0)
+      ) {
+        newIndex = delta < 0 || clipToTop ? 0 : this.stops.length - 1;
+        newStop = this.stops[newIndex];
+        clipped = true;
+        break;
+      }
+      // Sadly needed so that type narrowing understands that this.stops[newIndex] is
+      // targetable after I have checked that.
+      newStop = this.stops[newIndex];
+    } while (isTargetable(newStop) && filter && !filter(newStop));
+
+    if (!isTargetable(newStop)) {
+      return CursorMoveResult.ABORTED;
+    }
+
+    this._unDecorateTarget();
+
+    this.index = newIndex;
+    this.target = newStop;
+
+    if (getTargetHeight) {
+      this._targetHeight = getTargetHeight(this.target);
+    } else {
+      this._targetHeight = this.target.scrollHeight;
+    }
+
+    if (this.focusOnMove) {
+      this.target.focus();
+    }
+
+    this._decorateTarget();
+
+    return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED;
+  }
+
+  _decorateTarget() {
+    if (this.target && this.cursorTargetClass) {
+      this.target.classList.add(this.cursorTargetClass);
+    }
+  }
+
+  _unDecorateTarget() {
+    if (this.target && this.cursorTargetClass) {
+      this.target.classList.remove(this.cursorTargetClass);
+    }
+  }
+
+  @observe('stops')
+  _updateIndex() {
+    if (!this.target) {
+      this.index = -1;
+      return;
+    }
+
+    const newIndex = this.stops.indexOf(this.target);
+    if (newIndex === -1) {
+      this.unsetCursor();
+    } else {
+      this.index = newIndex;
+    }
+  }
+
+  /**
+   * Calculate where the element is relative to the window.
+   *
+   * @param target Target to scroll to.
+   * @return Distance to top of the target.
+   */
+  _getTop(target: HTMLElement) {
+    let top: number = target.offsetTop;
+    for (
+      let offsetParent = target.offsetParent;
+      offsetParent;
+      offsetParent = (offsetParent as HTMLElement).offsetParent
+    ) {
+      top += (offsetParent as HTMLElement).offsetTop;
+    }
+    return top;
+  }
+
+  /**
+   * @return
+   */
+  _targetIsVisible(top: number) {
+    const dims = this._getWindowDims();
+    return (
+      this.scrollMode === ScrollMode.KEEP_VISIBLE &&
+      top > dims.pageYOffset &&
+      top < dims.pageYOffset + dims.innerHeight
+    );
+  }
+
+  _calculateScrollToValue(top: number, target: HTMLElement) {
+    const dims = this._getWindowDims();
+    return top + -dims.innerHeight / 3 + target.offsetHeight / 2;
+  }
+
+  @observe('target')
+  _scrollToTarget() {
+    if (!this.target || this.scrollMode === ScrollMode.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
+      // would 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,
+    };
+  }
+}
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
deleted file mode 100644
index 3ed33d1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.ts
new file mode 100644
index 0000000..1489006
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 98a7d24..0000000
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ /dev/null
@@ -1,303 +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">
-<meta charset="utf-8">
-<title>gr-cursor-manager</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-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
-    <ul>
-      <li>A</li>
-      <li>B</li>
-      <li>C</li>
-      <li>D</li>
-    </ul>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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 and bottom not visible', () => {
-      sandbox.stub(element, '_targetIsVisible').returns(false);
-      element._scrollToTarget();
-      assert.isTrue(scrollStub.called);
-    });
-
-    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);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
new file mode 100644
index 0000000..33aeafc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -0,0 +1,365 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-cursor-manager.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {AbortStop, CursorMoveResult} from './gr-cursor-manager.js';
+
+const basicTestFixutre = fixtureFromTemplate(html`
+    <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
+    <ul>
+      <li>A</li>
+      <li>B</li>
+      <li>C</li>
+      <li>D</li>
+    </ul>
+`);
+
+suite('gr-cursor-manager tests', () => {
+  let element;
+  let list;
+
+  setup(() => {
+    const fixtureElements = basicTestFixutre.instantiate();
+    element = fixtureElements[0];
+    list = fixtureElements[1];
+  });
+
+  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.
+    let result = element.next();
+
+    // Confirm that the next stop is selected and that the previous stop is
+    // unselected.
+    assert.equal(result, CursorMoveResult.MOVED);
+    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.
+    result = element.next();
+
+    // We should still be at the end.
+    assert.equal(result, CursorMoveResult.CLIPPED);
+    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.
+    result = element.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+    result = element.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+    result = element.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+
+    // The element state should reflect the start 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')];
+    const result = element.next();
+
+    assert.equal(result, CursorMoveResult.MOVED);
+    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() resets the cursor when there are no stops', () => {
+    element.stops = [];
+    const result = element.next();
+
+    assert.equal(result, CursorMoveResult.NO_STOPS);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+  });
+
+  test('previous() goes to last element when no cursor is set', () => {
+    element.stops = [...list.querySelectorAll('li')];
+    const result = element.previous();
+
+    assert.equal(result, CursorMoveResult.MOVED);
+    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('previous() resets the cursor when there are no stops', () => {
+    element.stops = [];
+    const result = element.previous();
+
+    assert.equal(result, CursorMoveResult.NO_STOPS);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isFalse(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, {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('setCursorAtIndex with noScroll', () => {
+    sinon.stub(element, '_targetIsVisible').callsFake(() => false);
+    const scrollStub = sinon.stub(window, 'scrollTo');
+    element.stops = [...list.querySelectorAll('li')];
+    element.scrollMode = 'keep-visible';
+
+    element.setCursorAtIndex(1, true);
+    assert.isFalse(scrollStub.called);
+
+    element.setCursorAtIndex(2);
+    assert.isTrue(scrollStub.called);
+  });
+
+  test('move with filter', () => {
+    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.
+    element.next({filter: isLetterB});
+    assert.equal(element.index, 1);
+
+    // Nothing else meets the condition, should be at last stop.
+    element.next({filter: isLetterB});
+    assert.equal(element.index, 3);
+
+    // Should stay at last stop if try to proceed.
+    element.next({filter: isLetterB});
+    assert.equal(element.index, 3);
+
+    // Go back to the previous condition met. Should be back at.
+    // stop 1.
+    element.previous({filter: isLetterB});
+    assert.equal(element.index, 1);
+
+    // Go back. No more meet the condition. Should be at stop 0.
+    element.previous({filter: isLetterB});
+    assert.equal(element.index, 0);
+  });
+
+  test('focusOnMove prop', () => {
+    const listEls = [...list.querySelectorAll('li')];
+    for (let i = 0; i < listEls.length; i++) {
+      sinon.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(() => {
+      element.stops = [...list.querySelectorAll('li')];
+      element.scrollMode = 'keep-visible';
+
+      // There is a target which has a targetNext
+      element.setCursor(list.children[0]);
+      element._moveCursor(1);
+      scrollStub = sinon.stub(window, 'scrollTo');
+      window.innerHeight = 60;
+    });
+
+    test('Called when top and bottom not visible', () => {
+      sinon.stub(element, '_targetIsVisible').returns(false);
+      element._scrollToTarget();
+      assert.isTrue(scrollStub.called);
+    });
+
+    test('Not called when top and bottom visible', () => {
+      sinon.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 = sinon.stub(element, '_targetIsVisible').callsFake(
+          () => visibleStub.callCount === 2);
+      sinon.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 15,
+        innerHeight: 1000,
+        pageYOffset: 0,
+      });
+      sinon.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 = sinon.stub(element, '_targetIsVisible').callsFake(
+          () => visibleStub.callCount === 2);
+      sinon.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 25,
+        innerHeight: 1000,
+        pageYOffset: 0,
+      });
+      sinon.stub(element, '_calculateScrollToValue').returns(20);
+      element._scrollToTarget();
+      assert.isFalse(scrollStub.called);
+      assert.equal(visibleStub.callCount, 2);
+    });
+
+    test('_calculateScrollToValue', () => {
+      sinon.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 25,
+        innerHeight: 300,
+        pageYOffset: 0,
+      });
+      assert.equal(element._calculateScrollToValue(1000, {offsetHeight: 10}),
+          905);
+    });
+  });
+
+  suite('AbortStops', () => {
+    test('next() does not skip AbortStops', () => {
+      element.stops = [
+        document.createElement('li'),
+        new AbortStop(),
+        document.createElement('li'),
+      ];
+      element.setCursorAtIndex(0);
+
+      const result = element.next();
+
+      assert.equal(result, CursorMoveResult.ABORTED);
+      assert.equal(element.index, 0);
+    });
+
+    test('setCursorAtIndex() does not target AbortStops', () => {
+      element.stops = [
+        document.createElement('li'),
+        new AbortStop(),
+        document.createElement('li'),
+      ];
+      element.setCursorAtIndex(1);
+      assert.equal(element.index, -1);
+    });
+
+    test('moveToStart() does not target AbortStop', () => {
+      element.stops = [
+        new AbortStop(),
+        document.createElement('li'),
+        document.createElement('li'),
+      ];
+      element.moveToStart();
+      assert.equal(element.index, -1);
+    });
+
+    test('moveToEnd() does not target AbortStop', () => {
+      element.stops = [
+        document.createElement('li'),
+        document.createElement('li'),
+        new AbortStop(),
+      ];
+      element.moveToEnd();
+      assert.equal(element.index, -1);
+    });
+  });
+});
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
deleted file mode 100644
index 6f19587..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ /dev/null
@@ -1,266 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.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-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 Duration = {
-  HOUR: 1000 * 60 * 60,
-  DAY: 1000 * 60 * 60 * 24,
-};
-
-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 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
-  },
-};
-
-/**
- * @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,
-        notify: true,
-      },
-      showDateAndTime: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * When true, the detailed date appears in a GR-TOOLTIP rather than in the
-       * native browser tooltip.
-       */
-      hasTooltip: Boolean,
-
-      /**
-       * The title to be used as the native tooltip or by the tooltip behavior.
-       */
-      title: {
-        type: String,
-        reflectToAttribute: true,
-        computed: '_computeFullDateStr(dateStr, _timeFormat, _dateFormat)',
-      },
-
-      /** @type {?{short: string, full: string}} */
-      _dateFormat: Object,
-      _timeFormat: String, // No default value to prevent flickering.
-      _relative: Boolean, // No default value to prevent flickering.
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadPreferences();
-  }
-
-  _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);
-    }
-  }
-
-  _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);
-    }
-  }
-
-  _loadRelative() {
-    return this._getPreferences().then(prefs => {
-      // prefs.relative_date_in_change_table is not set when false.
-      this._relative = !!(prefs && prefs.relative_date_in_change_table);
-    });
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  /**
-   * Return true if date is within 24 hours and on the same day.
-   */
-  _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 {
-        return dateFromNow;
-      }
-    }
-    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);
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
new file mode 100644
index 0000000..c64dc2a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -0,0 +1,279 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-date-formatter_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {property, customElement} from '@polymer/decorators';
+import {
+  parseDate,
+  fromNow,
+  isValidDate,
+  isWithinDay,
+  isWithinHalfYear,
+  formatDate,
+  utcOffsetString,
+} from '../../../utils/date-util';
+import {TimeFormat, DateFormat} from '../../../constants/constants';
+import {assertNever} from '../../../utils/common-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {Timestamp} from '../../../types/common';
+
+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 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
+  },
+};
+
+interface DateFormatPair {
+  short: string;
+  full: string;
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-date-formatter': GrDateFormatter;
+  }
+}
+
+export interface GrDateFormatter {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-date-formatter')
+export class GrDateFormatter extends TooltipMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, notify: true})
+  dateStr: string | null = null;
+
+  @property({type: Boolean})
+  showDateAndTime = false;
+
+  /**
+   * When true, the detailed date appears in a GR-TOOLTIP rather than in the
+   * native browser tooltip.
+   */
+  @property({type: Boolean})
+  hasTooltip = false;
+
+  /**
+   * The title to be used as the native tooltip or by the tooltip behavior.
+   */
+  @property({
+    type: String,
+    reflectToAttribute: true,
+    computed: '_computeFullDateStr(dateStr, _timeFormat, _dateFormat)',
+  })
+  title = '';
+
+  /** @type {?{short: string, full: string}} */
+  @property({type: Object})
+  _dateFormat?: DateFormatPair;
+
+  @property({type: String})
+  _timeFormat?: string;
+
+  @property({type: Boolean})
+  _relative = false;
+
+  @property({type: Boolean})
+  forceRelative = false;
+
+  @property({type: Boolean})
+  relativeOptionNoAgo = false;
+
+  constructor() {
+    super();
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+  }
+
+  _getUtcOffsetString() {
+    return utcOffsetString();
+  }
+
+  _loadPreferences() {
+    return this._getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        this._timeFormat = TimeFormats.TIME_24;
+        this._dateFormat = DateFormats.STD;
+        this._relative = this.forceRelative;
+        return;
+      }
+      return Promise.all([this._loadTimeFormat(), this._loadRelative()]);
+    });
+  }
+
+  _loadTimeFormat() {
+    return this._getPreferences().then(preferences => {
+      if (!preferences) {
+        throw Error('Preferences is not set');
+      }
+      this._decideTimeFormat(preferences.time_format);
+      this._decideDateFormat(preferences.date_format);
+    });
+  }
+
+  _decideTimeFormat(timeFormat: TimeFormat) {
+    switch (timeFormat) {
+      case TimeFormat.HHMM_12:
+        this._timeFormat = TimeFormats.TIME_12;
+        break;
+      case TimeFormat.HHMM_24:
+        this._timeFormat = TimeFormats.TIME_24;
+        break;
+      default:
+        assertNever(timeFormat, `Invalid time format: ${timeFormat}`);
+    }
+  }
+
+  _decideDateFormat(dateFormat: DateFormat) {
+    switch (dateFormat) {
+      case DateFormat.STD:
+        this._dateFormat = DateFormats.STD;
+        break;
+      case DateFormat.US:
+        this._dateFormat = DateFormats.US;
+        break;
+      case DateFormat.ISO:
+        this._dateFormat = DateFormats.ISO;
+        break;
+      case DateFormat.EURO:
+        this._dateFormat = DateFormats.EURO;
+        break;
+      case DateFormat.UK:
+        this._dateFormat = DateFormats.UK;
+        break;
+      default:
+        assertNever(dateFormat, `Invalid date format: ${dateFormat}`);
+    }
+  }
+
+  _loadRelative() {
+    return this._getPreferences().then(prefs => {
+      // prefs.relative_date_in_change_table is not set when false.
+      this._relative =
+        this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
+    });
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _computeDateStr(
+    dateStr?: Timestamp,
+    timeFormat?: string,
+    dateFormat?: DateFormatPair,
+    relative?: boolean,
+    showDateAndTime?: boolean
+  ) {
+    if (!dateStr || !timeFormat || !dateFormat) {
+      return '';
+    }
+    const date = parseDate(dateStr);
+    if (!isValidDate(date)) {
+      return '';
+    }
+    if (relative) {
+      return fromNow(date, this.relativeOptionNoAgo);
+    }
+    const now = new Date();
+    let format = dateFormat.full;
+    if (isWithinDay(now, date)) {
+      format = timeFormat;
+    } else {
+      if (isWithinHalfYear(now, date)) {
+        format = dateFormat.short;
+      }
+      if (this.showDateAndTime || showDateAndTime) {
+        format = `${format} ${timeFormat}`;
+      }
+    }
+    return formatDate(date, format);
+  }
+
+  _timeToSecondsFormat(timeFormat: string | undefined) {
+    return timeFormat === TimeFormats.TIME_12
+      ? TimeFormats.TIME_12_WITH_SEC
+      : TimeFormats.TIME_24_WITH_SEC;
+  }
+
+  _computeFullDateStr(
+    dateStr?: Timestamp,
+    timeFormat?: string,
+    dateFormat?: DateFormatPair
+  ) {
+    // Polymer 2: check for undefined
+    if ([dateStr, timeFormat].includes(undefined) || !dateFormat) {
+      return undefined;
+    }
+
+    if (!dateStr) {
+      return '';
+    }
+    const date = parseDate(dateStr);
+    if (!isValidDate(date)) {
+      return '';
+    }
+    let format = dateFormat.full + ', ';
+    format += this._timeToSecondsFormat(timeFormat);
+    return formatDate(date, format) + this._getUtcOffsetString();
+  }
+}
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
deleted file mode 100644
index 2571065..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
new file mode 100644
index 0000000..a5dd6d0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 7169ef27..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ /dev/null
@@ -1,448 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-date-formatter</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-date-formatter date-str="2015-09-24 23:30:17.033000000"></gr-date-formatter>
-  </template>
-</test-fixture>
-
-<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();
-  });
-
-  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'), '');
-    });
-
-    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);
-    });
-  });
-
-  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-date-formatter/gr-date-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
new file mode 100644
index 0000000..0a7f6dd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
@@ -0,0 +1,430 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-date-formatter.js';
+import {parseDate} from '../../../utils/date-util.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-date-formatter date-str="2015-09-24 23:30:17.033000000"></gr-date-formatter>
+`);
+
+suite('gr-date-formatter tests', () => {
+  let element;
+
+  setup(() => {
+
+  });
+
+  /**
+   * Parse server-formatter date and normalize into current timezone.
+   */
+  function normalizedDate(dateStr) {
+    const d = parseDate(dateStr);
+    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
+    return d;
+  }
+
+  function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
+      expectedTooltip) {
+    // Normalize and convert the date to mimic server response.
+    dateStr = normalizedDate(dateStr)
+        .toJSON()
+        .replace('T', ' ')
+        .slice(0, -1);
+    sinon.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;
+    flush();
+    assert.equal(span.textContent.trim(), expectedWithDateAndTime);
+  }
+
+  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 = basicFixture.instantiate();
+      sinon.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', () => {
+      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');
+    });
+
+    test('Within 24 hours on different days', () => {
+      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');
+    });
+
+    test('More than 24 hours but less than six months', () => {
+      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');
+    });
+
+    test('More than six months', () => {
+      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');
+    });
+  });
+
+  suite('US + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'US',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+
+    test('Within 24 hours on different days', () => {
+      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');
+    });
+
+    test('More than 24 hours but less than six months', () => {
+      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');
+    });
+  });
+
+  suite('ISO + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'ISO',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+
+    test('Within 24 hours on different days', () => {
+      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');
+    });
+
+    test('More than 24 hours but less than six months', () => {
+      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');
+    });
+  });
+
+  suite('EURO + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'EURO',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+
+    test('Within 24 hours on different days', () => {
+      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');
+    });
+
+    test('More than 24 hours but less than six months', () => {
+      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');
+    });
+  });
+
+  suite('UK + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'UK',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+
+    test('Within 24 hours on different days', () => {
+      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');
+    });
+
+    test('More than 24 hours but less than six months', () => {
+      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');
+    });
+  });
+
+  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 = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+  });
+
+  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 = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+  });
+
+  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 = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+  });
+
+  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 = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+  });
+
+  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 = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+  });
+
+  suite('relative date preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_12',
+      date_format: 'STD',
+      relative_date_in_change_table: true,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', () => {
+      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');
+    });
+
+    test('More than six months', () => {
+      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');
+    });
+  });
+
+  suite('logged in', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_12',
+      date_format: 'US',
+      relative_date_in_change_table: true,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      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 = basicFixture.instantiate();
+      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);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
deleted file mode 100644
index db64661..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @extends Polymer.Element
- */
-class GrDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-dialog'; }
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  static get properties() {
-    return {
-      confirmLabel: {
-        type: String,
-        value: 'Confirm',
-      },
-      // Supplying an empty cancel label will hide the button completely.
-      cancelLabel: {
-        type: String,
-        value: 'Cancel',
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-      },
-      confirmOnEnter: {
-        type: Boolean,
-        value: false,
-      },
-      confirmTooltip: {
-        type: String,
-        observer: '_handleConfirmTooltipUpdate',
-      },
-    };
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
-  }
-
-  _handleConfirmTooltipUpdate(confirmTooltip) {
-    if (confirmTooltip) {
-      this.$.confirm.setAttribute('has-tooltip', true);
-    } else {
-      this.$.confirm.removeAttribute('has-tooltip');
-    }
-  }
-
-  _handleConfirm(e) {
-    if (this.disabled) { return; }
-
-    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,
-    }));
-  }
-
-  _handleKeydown(e) {
-    if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
-  }
-
-  resetFocus() {
-    this.$.confirm.focus();
-  }
-
-  _computeCancelClass(cancelLabel) {
-    return cancelLabel.length ? '' : 'hidden';
-  }
-}
-
-customElements.define(GrDialog.is, GrDialog);
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
new file mode 100644
index 0000000..fa6403a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -0,0 +1,128 @@
+/**
+ * @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 '../gr-button/gr-button';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-dialog_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {GrButton} from '../gr-button/gr-button';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-dialog': GrDialog;
+  }
+}
+
+export interface GrDialog {
+  $: {
+    confirm: GrButton;
+  };
+}
+
+@customElement('gr-dialog')
+export class GrDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: String})
+  confirmLabel = 'Confirm';
+
+  // Supplying an empty cancel label will hide the button completely.
+  @property({type: String})
+  cancelLabel = 'Cancel';
+
+  @property({type: Boolean})
+  disabled = false;
+
+  @property({type: Boolean})
+  confirmOnEnter = false;
+
+  @property({type: String})
+  confirmTooltip?: string;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
+
+  @observe('confirmTooltip')
+  _handleConfirmTooltipUpdate(confirmTooltip?: string) {
+    if (confirmTooltip) {
+      this.$.confirm.setAttribute('has-tooltip', 'true');
+    } else {
+      this.$.confirm.removeAttribute('has-tooltip');
+    }
+  }
+
+  _handleConfirm(e: KeyboardEvent) {
+    if (this.disabled) {
+      return;
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCancelTap(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleKeydown(e: KeyboardEvent) {
+    if (this.confirmOnEnter && e.keyCode === 13) {
+      this._handleConfirm(e);
+    }
+  }
+
+  resetFocus() {
+    this.$.confirm.focus();
+  }
+
+  _computeCancelClass(cancelLabel: string) {
+    return cancelLabel.length ? '' : 'hidden';
+  }
+}
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
deleted file mode 100644
index b32f871..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.ts
new file mode 100644
index 0000000..f8ddcfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.ts
@@ -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';
+
+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="heading-3"><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
deleted file mode 100644
index 1060e82..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
+++ /dev/null
@@ -1,111 +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">
-<meta charset="utf-8">
-<title>gr-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-dialog></gr-dialog>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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'));
-    });
-
-    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-dialog/gr-dialog_test.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
new file mode 100644
index 0000000..1238168
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-dialog.js';
+import {isHidden} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-dialog');
+
+suite('gr-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('events', () => {
+    const confirm = sinon.stub();
+    const cancel = sinon.stub();
+    element.addEventListener('confirm', confirm);
+    element.addEventListener('cancel', cancel);
+
+    MockInteractions.tap(
+        element.shadowRoot.querySelector('gr-button[primary]'));
+    assert.equal(confirm.callCount, 1);
+
+    MockInteractions.tap(
+        element.shadowRoot.querySelector('gr-button:not([primary])'));
+    assert.equal(cancel.callCount, 1);
+  });
+
+  test('confirmOnEnter', () => {
+    element.confirmOnEnter = false;
+    const handleConfirmStub = sinon.stub(element, '_handleConfirm');
+    const handleKeydownSpy = sinon.spy(element, '_handleKeydown');
+    MockInteractions.pressAndReleaseKeyOn(
+        element.shadowRoot.querySelector('main'), 13, null, 'enter');
+    flush();
+
+    assert.isTrue(handleKeydownSpy.called);
+    assert.isFalse(handleConfirmStub.called);
+
+    element.confirmOnEnter = true;
+    MockInteractions.pressAndReleaseKeyOn(
+        element.shadowRoot.querySelector('main'), 13, null, 'enter');
+    flush();
+
+    assert.isTrue(handleConfirmStub.called);
+  });
+
+  test('resetFocus', () => {
+    const focusStub = sinon.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'));
+    });
+
+    test('tooltip added if confirm tooltip is passed', () => {
+      element.confirmTooltip = 'confirm tooltip';
+      flush();
+      assert(element.$.confirm.getAttribute('has-tooltip'));
+    });
+  });
+
+  test('empty cancel label hides cancel btn', () => {
+    assert.isFalse(isHidden(element.$.cancel));
+    element.cancelLabel = '';
+    flush();
+
+    assert.isTrue(isHidden(element.$.cancel));
+  });
+});
+
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
deleted file mode 100644
index 00f9078..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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,
-        value: false,
-      },
-
-      /** @type {?} */
-      diffPrefs: Object,
-    };
-  }
-
-  loadData() {
-    return this.$.restAPI.getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    });
-  }
-
-  _handleDiffPrefsChanged() {
-    this.hasUnsavedChanges = true;
-  }
-
-  _handleLineWrappingTap() {
-    this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleShowTabsTap() {
-    this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleShowTrailingWhitespaceTap() {
-    this.set('diffPrefs.show_whitespace_errors',
-        this.$.showTrailingWhitespaceInput.checked);
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleSyntaxHighlightTap() {
-    this.set('diffPrefs.syntax_highlighting',
-        this.$.syntaxHighlightInput.checked);
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleAutomaticReviewTap() {
-    this.set('diffPrefs.manual_review',
-        !this.$.automaticReviewInput.checked);
-    this._handleDiffPrefsChanged();
-  }
-
-  save() {
-    return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(res => {
-      this.hasUnsavedChanges = false;
-    });
-  }
-}
-
-customElements.define(GrDiffPreferences.is, GrDiffPreferences);
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
new file mode 100644
index 0000000..02d039a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -0,0 +1,112 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-select/gr-select';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-preferences_html';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {DiffPreferencesInfo} from '../../../types/common';
+import {GrSelect} from '../gr-select/gr-select';
+
+export interface GrDiffPreferences {
+  $: {
+    restAPI: RestApiService & Element;
+    lineWrappingInput: HTMLInputElement;
+    showTabsInput: HTMLInputElement;
+    showTrailingWhitespaceInput: HTMLInputElement;
+    automaticReviewInput: HTMLInputElement;
+    syntaxHighlightInput: HTMLInputElement;
+    contextSelect: GrSelect;
+  };
+  save(): Promise<void>;
+}
+
+@customElement('gr-diff-preferences')
+export class GrDiffPreferences extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, notify: true})
+  hasUnsavedChanges = false;
+
+  @property({type: Object})
+  diffPrefs?: DiffPreferencesInfo;
+
+  loadData() {
+    return this.$.restAPI.getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    });
+  }
+
+  _handleDiffPrefsChanged() {
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleLineWrappingTap() {
+    this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleShowTabsTap() {
+    this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleShowTrailingWhitespaceTap() {
+    this.set(
+      'diffPrefs.show_whitespace_errors',
+      this.$.showTrailingWhitespaceInput.checked
+    );
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleSyntaxHighlightTap() {
+    this.set(
+      'diffPrefs.syntax_highlighting',
+      this.$.syntaxHighlightInput.checked
+    );
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleAutomaticReviewTap() {
+    this.set('diffPrefs.manual_review', !this.$.automaticReviewInput.checked);
+    this._handleDiffPrefsChanged();
+  }
+
+  save() {
+    if (!this.diffPrefs)
+      return Promise.reject(new Error('Missing diff preferences'));
+    return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(_ => {
+      this.hasUnsavedChanges = false;
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-preferences': 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
deleted file mode 100644
index 3ea40d9..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js
+++ /dev/null
@@ -1,195 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
new file mode 100644
index 0000000..54022ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 2750d67..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
+++ /dev/null
@@ -1,124 +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">
-<meta charset="utf-8">
-<title>gr-diff-preferences</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-diff-preferences></gr-diff-preferences>
-  </template>
-</test-fixture>
-
-<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');
-      }
-    }
-  }
-
-  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();
-  });
-
-  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);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
new file mode 100644
index 0000000..ced8c6c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
@@ -0,0 +1,108 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-preferences.js';
+
+const basicFixture = fixtureFromElement('gr-diff-preferences');
+
+suite('gr-diff-preferences tests', () => {
+  let element;
+
+  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');
+      }
+    }
+  }
+
+  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 = basicFixture.instantiate();
+
+    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);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', () => {
+    sinon.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);
+    });
+  });
+});
+
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
deleted file mode 100644
index fcc09c4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
+++ /dev/null
@@ -1,105 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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,
-        value: false,
-        observer: '_loggedInChanged',
-      },
-      schemes: Array,
-      selectedScheme: {
-        type: String,
-        notify: true,
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-  }
-
-  focusOnCopy() {
-    this.shadowRoot.querySelector('gr-shell-command').focusOnCopy();
-  }
-
-  _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});
-      }
-    }
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
new file mode 100644
index 0000000..97747a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -0,0 +1,121 @@
+/**
+ * @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/paper-tabs/paper-tabs';
+import '../gr-shell-command/gr-shell-command';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-download-commands_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-download-commands': GrDownloadCommands;
+  }
+}
+
+export interface GrDownloadCommands {
+  $: {
+    downloadTabs: PaperTabsElement;
+    restAPI: RestApiService & Element;
+  };
+}
+
+export interface Command {
+  title: string;
+  command: string;
+}
+
+@customElement('gr-download-commands')
+export class GrDownloadCommands extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  // TODO(TS): maybe default to [] as only used in dom-repeat
+  @property({type: Array})
+  comamnds?: Command[];
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Array})
+  schemes: string[] = [];
+
+  @property({type: String, notify: true})
+  selectedScheme?: string;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+  }
+
+  focusOnCopy() {
+    // TODO(TS): remove ! assertion later
+    this.shadowRoot!.querySelector('gr-shell-command')!.focusOnCopy();
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  @observe('_loggedIn')
+  _loggedInChanged(loggedIn: boolean) {
+    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: CustomEvent<{value: number}>) {
+    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});
+      }
+    }
+  }
+
+  _computeSelected(schemes: string[], selectedScheme?: string) {
+    return `${schemes.findIndex(scheme => scheme === selectedScheme) || 0}`;
+  }
+
+  _computeShowTabs(schemes: string[]) {
+    return schemes.length > 1 ? '' : 'hidden';
+  }
+
+  // TODO: maybe unify with strToClassName from dom-util
+  _computeClass(title: string) {
+    // Only retain [a-z] chars, so "Cherry Pick" becomes "cherrypick".
+    return '_label_' + title.replace(/[^a-z]+/gi, '').toLowerCase();
+  }
+}
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
deleted file mode 100644
index 599fdf2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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 {
-      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_html.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
new file mode 100644
index 0000000..ac90967
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
@@ -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';
+
+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 {
+      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
+        class$="[[_computeClass(command.title)]]"
+        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
deleted file mode 100644
index 237fbe0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
+++ /dev/null
@@ -1,155 +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">
-<meta charset="utf-8">
-<title>gr-download-commands</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-download-commands></gr-download-commands>
-  </template>
-</test-fixture>
-
-<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();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('unauthenticated', () => {
-    setup(done => {
-      element = fixture('basic');
-      element.schemes = SCHEMES;
-      element.commands = COMMANDS;
-      element.selectedScheme = SELECTED_SCHEME;
-      flushAsynchronousOperations();
-      flush(done);
-    });
-
-    test('focusOnCopy', () => {
-      const focusStub = sandbox.stub(element.shadowRoot
-          .querySelector('gr-shell-command'),
-      'focusOnCopy');
-      element.focusOnCopy();
-      assert.isTrue(focusStub.called);
-    });
-
-    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'});
-        },
-      });
-      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'});
-        },
-      });
-      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-download-commands/gr-download-commands_test.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
new file mode 100644
index 0000000..5429506
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
@@ -0,0 +1,133 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-download-commands.js';
+import {isHidden} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-download-commands');
+
+suite('gr-download-commands', () => {
+  let element;
+
+  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(() => {
+
+  });
+
+  suite('unauthenticated', () => {
+    setup(async () => {
+      element = basicFixture.instantiate();
+      element.schemes = SCHEMES;
+      element.commands = COMMANDS;
+      element.selectedScheme = SELECTED_SCHEME;
+      await flush();
+    });
+
+    test('focusOnCopy', () => {
+      const focusStub = sinon.stub(element.shadowRoot
+          .querySelector('gr-shell-command'),
+      'focusOnCopy');
+      element.focusOnCopy();
+      assert.isTrue(focusStub.called);
+    });
+
+    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', () => {
+      assert.equal(element.$.downloadTabs.selected, '0');
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('[data-scheme="ssh"]'));
+      flush();
+      assert.equal(element.selectedScheme, 'ssh');
+      assert.equal(element.$.downloadTabs.selected, '2');
+    });
+
+    test('loads scheme from preferences', () => {
+      stub('gr-rest-api-interface', {
+        getPreferences() {
+          return Promise.resolve({download_scheme: 'repo'});
+        },
+      });
+      element._loggedIn = true;
+      assert.isTrue(element.$.restAPI.getPreferences.called);
+      return element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+        assert.equal(element.selectedScheme, 'repo');
+      });
+    });
+
+    test('normalize scheme from preferences', () => {
+      stub('gr-rest-api-interface', {
+        getPreferences() {
+          return Promise.resolve({download_scheme: 'REPO'});
+        },
+      });
+      element._loggedIn = true;
+      return element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+        assert.equal(element.selectedScheme, 'repo');
+      });
+    });
+
+    test('saves scheme to preferences', () => {
+      element._loggedIn = true;
+      const savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences')
+          .callsFake(() => Promise.resolve());
+
+      flush();
+
+      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'));
+    });
+  });
+});
+
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
deleted file mode 100644
index 6b250de..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
+++ /dev/null
@@ -1,148 +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.
- */
-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 changes
-   *
-   * @event value-change
-   *
-   * @property {string|number} value
-   */
-
-  static get properties() {
-    return {
-      initialCount: Number,
-      /** @type {!Array<!Defs.item>} */
-      items: Object,
-      text: String,
-      disabled: {
-        type: Boolean,
-        value: false,
-      },
-      value: {
-        type: String,
-        notify: true,
-      },
-    };
-  }
-
-  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 button to open the dropdown.
-   *
-   * @param {!Event} e
-   */
-  _showDropdownTapHandler(e) {
-    this._open();
-  }
-
-  /**
-   * Open the dropdown.
-   */
-  _open() {
-    this.$.dropdown.open();
-  }
-
-  _computeMobileText(item) {
-    return item.mobileText ? item.mobileText : item.text;
-  }
-
-  _handleValueChange(value, items) {
-    // Polymer 2: check for undefined
-    if ([value, items].some(arg => arg === undefined)) {
-      return;
-    }
-
-    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.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
new file mode 100644
index 0000000..2ea72ca
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -0,0 +1,158 @@
+/**
+ * @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-dropdown/iron-dropdown';
+import '@polymer/paper-item/paper-item';
+import '@polymer/paper-listbox/paper-listbox';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+import '../gr-date-formatter/gr-date-formatter';
+import '../gr-select/gr-select';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-dropdown-list_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {Timestamp} from '../../../types/common';
+
+/**
+ * fired when the selected value of the dropdown changes
+ *
+ * @event {change}
+ */
+
+/**
+ * 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.
+ */
+export interface DropdownItem {
+  text: string;
+  value: string | number;
+  bottomText?: string;
+  triggerText?: string;
+  mobileText?: string;
+  date?: Timestamp;
+  disabled?: boolean;
+}
+
+export interface GrDropdownList {
+  $: {
+    dropdown: IronDropdownElement;
+  };
+}
+
+export interface ValueChangeDetail {
+  value: string;
+}
+
+export type DropDownValueChangeEvent = CustomEvent<ValueChangeDetail>;
+
+@customElement('gr-dropdown-list')
+export class GrDropdownList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the selected value changes
+   *
+   * @event value-change
+   *
+   * @property {string|number} value
+   */
+
+  @property({type: Number})
+  initialCount = 75;
+
+  @property({type: Object})
+  items?: DropdownItem[];
+
+  @property({type: String})
+  text?: string;
+
+  @property({type: Boolean})
+  disabled = false;
+
+  @property({type: String, notify: true})
+  value?: string;
+
+  @property({type: Boolean})
+  showCopyForTriggerText = false;
+
+  /**
+   * Handle a click on the iron-dropdown element.
+   */
+  _handleDropdownClick() {
+    // 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.
+   */
+  _showDropdownTapHandler() {
+    this.open();
+  }
+
+  /**
+   * Open the dropdown.
+   */
+  open() {
+    this.$.dropdown.open();
+  }
+
+  _computeMobileText(item: DropdownItem) {
+    return item.mobileText ? item.mobileText : item.text;
+  }
+
+  @observe('value', 'items')
+  _handleValueChange(value?: string, items?: DropdownItem[]) {
+    if (!value || !items) {
+      return;
+    }
+    const selectedObj = items.find(item => `${item.value}` === `${value}`);
+    if (!selectedObj) {
+      return;
+    }
+    this.text = selectedObj.triggerText
+      ? selectedObj.triggerText
+      : selectedObj.text;
+    const detail: ValueChangeDetail = {value};
+    this.dispatchEvent(
+      new CustomEvent('value-change', {
+        detail,
+        bubbles: false,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-dropdown-list': 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
deleted file mode 100644
index 6e5e461..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js
+++ /dev/null
@@ -1,173 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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}}"
-    >
-      <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_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
new file mode 100644
index 0000000..28eb6a4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
@@ -0,0 +1,179 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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;
+      min-width: 266px;
+    }
+    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-copy-clipboard
+      hidden="[[!showCopyForTriggerText]]"
+      hide-input=""
+      text="[[text]]"
+    ></gr-copy-clipboard>
+  </gr-button>
+  <iron-dropdown
+    id="dropdown"
+    vertical-align="top"
+    horizontal-align="left"
+    dynamic-align
+    no-overlap
+    allow-outside-scroll="true"
+    on-click="_handleDropdownClick"
+  >
+    <paper-listbox
+      class="dropdown-content"
+      slot="dropdown-content"
+      attr-for-selected="data-value"
+      selected="{{value}}"
+    >
+      <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
deleted file mode 100644
index b64d5f7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
+++ /dev/null
@@ -1,172 +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">
-<meta charset="utf-8">
-<title>gr-dropdown-list</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-dropdown-list></gr-dropdown-list>
-  </template>
-</test-fixture>
-
-<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();
-  });
-
-  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('_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: '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);
-
-      // 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);
-
-      // 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);
-
-      // Since there is mobile specific text, it should that.
-      assert.equal(mobileItems[1].text, element.items[1].mobileText);
-
-      // Since this item is selected, and it has triggerText defined, that
-      // should be used.
-      assert.equal(element.text, element.items[1].triggerText);
-
-      // 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);
-
-      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);
-
-      // 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();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
new file mode 100644
index 0000000..e3d7ed70
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
@@ -0,0 +1,167 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-dropdown-list.js';
+
+const basicFixture = fixtureFromElement('gr-dropdown-list');
+
+suite('gr-dropdown-list tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('hide copy by default', () => {
+    const copyEl = element.shadowRoot
+        .querySelector('#triggerText + gr-copy-clipboard');
+    assert.isTrue(!!copyEl);
+    assert.isTrue(copyEl.hidden);
+  });
+
+  test('show copy if enabled', () => {
+    element.showCopyForTriggerText = true;
+    flush();
+    const copyEl = element.shadowRoot.querySelector(
+        '#triggerText + gr-copy-clipboard');
+    assert.isTrue(!!copyEl);
+    assert.isFalse(copyEl.hidden);
+  });
+
+  test('tap on trigger opens menu', () => {
+    sinon.stub(element, 'open')
+        .callsFake(() => { element.$.dropdown.open(); });
+    assert.isFalse(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isTrue(element.$.dropdown.opened);
+  });
+
+  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', async () => {
+    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.shadowRoot
+        .querySelector('paper-listbox').selected, element.value);
+    assert.equal(element.text, 'Button Text 2');
+    await flush();
+
+    const items = element.root.querySelectorAll('paper-item');
+    const mobileItems = 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);
+
+    assert.isNotOk(items[0].querySelector('gr-date-formatter'));
+    assert.isNotOk(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(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);
+
+    // 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(items[1].querySelector('gr-date-formatter'));
+    assert.isOk(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(items[1].querySelector('.topContent div')
+        .innerText, element.items[1].text);
+
+    // Since there is mobile specific text, it should that.
+    assert.equal(mobileItems[1].text, element.items[1].mobileText);
+
+    // Since this item is selected, and it has triggerText defined, that
+    // should be used.
+    assert.equal(element.text, element.items[1].triggerText);
+
+    // 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);
+
+    assert.isOk(items[2].querySelector('gr-date-formatter'));
+    assert.isOk(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(items[2].querySelector('.topContent div')
+        .innerText, element.items[2].text);
+
+    // 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]);
+    flush();
+    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);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
deleted file mode 100644
index 12a2025..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ /dev/null
@@ -1,335 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../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';
-
-const REL_NOOPENER = 'noopener';
-const REL_EXTERNAL = 'external';
-
-/**
- * @extends Polymer.Element
- */
-class GrDropdown extends mixinBehaviors( [
-  BaseUrlBehavior,
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-dropdown'; }
-  /**
-   * Fired when a non-link dropdown item with the given ID is tapped.
-   *
-   * @event tap-item-<id>
-   */
-
-  /**
-   * Fired when a non-link dropdown item is tapped.
-   *
-   * @event tap-item
-   */
-
-  static get properties() {
-    return {
-      items: {
-        type: Array,
-        observer: '_resetCursorStops',
-      },
-      downArrow: Boolean,
-      topContent: Object,
-      horizontalAlign: {
-        type: String,
-        value: 'left',
-      },
-
-      /**
-       * Style the dropdown trigger as a link (rather than a button).
-       */
-      link: {
-        type: Boolean,
-        value: false,
-      },
-
-      verticalOffset: {
-        type: Number,
-        value: 40,
-      },
-
-      /**
-       * List the IDs of dropdown buttons to be disabled. (Note this only
-       * diisables bittons and not link entries.)
-       */
-      disabledIds: {
-        type: Array,
-        value() { return []; },
-      },
-
-      /**
-       * The elements of the list.
-       */
-      _listElements: {
-        type: Array,
-        value() { return []; },
-      },
-    };
-  }
-
-  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) {
-    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();
-    }
-  }
-
-  /**
-   * 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));
-    }
-  }
-
-  /**
-   * 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) {
-      flush();
-      this._listElements = Array.from(
-          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.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
new file mode 100644
index 0000000..d64b1c0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -0,0 +1,345 @@
+/**
+ * @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 '@polymer/iron-dropdown/iron-dropdown';
+import '../gr-button/gr-button';
+import '../gr-cursor-manager/gr-cursor-manager';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-tooltip-content/gr-tooltip-content';
+import '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-dropdown_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
+import {property, customElement, observe} from '@polymer/decorators';
+
+const REL_NOOPENER = 'noopener';
+const REL_EXTERNAL = 'external';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-dropdown': GrDropdown;
+  }
+}
+
+export interface GrDropdown {
+  $: {
+    dropdown: IronDropdownElement;
+    cursor: GrCursorManager;
+  };
+}
+
+export interface DropdownLink {
+  url?: string;
+  name?: string;
+  external?: boolean;
+  target?: string | null;
+  download?: boolean;
+  id?: string;
+  tooltip?: string;
+}
+
+interface DisableIdsRecord {
+  base: string[];
+}
+
+interface Content {
+  text: string;
+  bold?: boolean;
+}
+
+@customElement('gr-dropdown')
+export class GrDropdown extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when a non-link dropdown item with the given ID is tapped.
+   *
+   * @event tap-item-<id>
+   */
+
+  /**
+   * Fired when a non-link dropdown item is tapped.
+   *
+   * @event tap-item
+   */
+
+  @property({type: Array})
+  items?: DropdownLink[];
+
+  @property({type: Boolean})
+  downArrow?: boolean;
+
+  @property({type: Array})
+  topContent?: Content[];
+
+  @property({type: String})
+  horizontalAlign = 'left';
+
+  /**
+   * Style the dropdown trigger as a link (rather than a button).
+   */
+
+  @property({type: Boolean})
+  link = false;
+
+  @property({type: Number})
+  verticalOffset = 40;
+
+  /**
+   * List the IDs of dropdown buttons to be disabled. (Note this only
+   * disables buttons and not link entries.)
+   */
+  @property({type: Array})
+  disabledIds: string[] = [];
+
+  /**
+   * The elements of the list.
+   */
+  @property({type: Array})
+  _listElements: Element[] = [];
+
+  get keyBindings() {
+    return {
+      down: '_handleDown',
+      'enter space': '_handleEnter',
+      tab: '_handleTab',
+      up: '_handleUp',
+    };
+  }
+
+  /**
+   * Handle the up key.
+   */
+  _handleUp(e: MouseEvent) {
+    if (this.$.dropdown.opened) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.$.cursor.previous();
+    } else {
+      this._open();
+    }
+  }
+
+  /**
+   * Handle the down key.
+   */
+  _handleDown(e: MouseEvent) {
+    if (this.$.dropdown.opened) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.$.cursor.next();
+    } else {
+      this._open();
+    }
+  }
+
+  /**
+   * Handle the tab key.
+   */
+  _handleTab(e: MouseEvent) {
+    if (this.$.dropdown.opened) {
+      // Tab in a native select is a no-op. Emulate this.
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  }
+
+  /**
+   * Handle the enter key.
+   */
+  _handleEnter(e: MouseEvent) {
+    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.
+      if (this.$.cursor.target !== null) {
+        const el = this.$.cursor.target.querySelector(':not([hidden]) a');
+        if (el) {
+          (el as HTMLElement).click();
+        }
+      }
+    } else {
+      this._open();
+    }
+  }
+
+  /**
+   * Handle a click on the iron-dropdown element.
+   */
+  _handleDropdownClick() {
+    this._close();
+  }
+
+  /**
+   * Handle a click on the button to open the dropdown.
+   */
+  _dropdownTriggerTapHandler(e: MouseEvent) {
+    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);
+    if (this.$.cursor.target !== null) 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 bold Whether the item is bold.
+   * @return The class for the top-content item.
+   */
+  _getClassIfBold(bold: boolean) {
+    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.
+   *
+   * @return The scheme-relative URL.
+   */
+  _computeURLHelper(host: string, path: string) {
+    const base = path.startsWith(getBaseUrl()) ? '' : 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 path The path for the URL.
+   * @return The scheme-relative URL.
+   */
+  _computeRelativeURL(path: string) {
+    const host = window.location.host;
+    return this._computeURLHelper(host, path);
+  }
+
+  /**
+   * Compute the URL for a link object.
+   */
+  _computeLinkURL(link: DropdownLink) {
+    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.
+   */
+  _computeLinkRel(link: DropdownLink) {
+    // 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.
+   */
+  _handleItemTap(e: MouseEvent) {
+    if (e.target === null || !this.items) {
+      return;
+    }
+    const id = (e.target as Element).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,
+            bubbles: true,
+            composed: true,
+          })
+        );
+      }
+      this.dispatchEvent(new CustomEvent('tap-item-' + id));
+    }
+  }
+
+  /**
+   * If a dropdown item is shown as a button, get the class for the button.
+   *
+   * @param disabledIdsRecord The change record for the disabled IDs
+   *     list.
+   * @return The class for the item button.
+   */
+  _computeDisabledClass(id: string, disabledIdsRecord: DisableIdsRecord) {
+    return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
+  }
+
+  /**
+   * Recompute the stops for the dropdown item cursor.
+   */
+  @observe('items')
+  _resetCursorStops() {
+    if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
+      flush();
+      this._listElements =
+        this.root !== null ? Array.from(this.root.querySelectorAll('li')) : [];
+    }
+  }
+
+  _computeHasTooltip(tooltip?: string) {
+    return !!tooltip;
+  }
+
+  _computeIsDownload(link: DropdownLink) {
+    return !!link.download;
+  }
+}
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
deleted file mode 100644
index d0b0d09..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js
+++ /dev/null
@@ -1,169 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
new file mode 100644
index 0000000..8ea0d21
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
@@ -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';
+
+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-mode="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
deleted file mode 100644
index d17cc1a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ /dev/null
@@ -1,208 +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">
-<meta charset="utf-8">
-<title>gr-dropdown</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-dropdown></gr-dropdown>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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('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);
-
-      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-dropdown/gr-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
new file mode 100644
index 0000000..515644b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
@@ -0,0 +1,190 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-dropdown.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-dropdown');
+
+suite('gr-dropdown tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeIsDownload', () => {
+    assert.isTrue(element._computeIsDownload({download: true}));
+    assert.isFalse(element._computeIsDownload({download: false}));
+  });
+
+  test('tap on trigger opens menu, then closes', () => {
+    sinon.stub(element, '_open')
+        .callsFake(() => { element.$.dropdown.open(); });
+    sinon.stub(element, '_close')
+        .callsFake(() => { 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'}];
+    flush();
+    const topItems = 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 = sinon.stub();
+    const tapped = sinon.stub();
+    element.addEventListener('tap-item-foo', fooTapped);
+    element.addEventListener('tap-item', tapped);
+    flush();
+    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 = sinon.stub();
+    const tapped = sinon.stub();
+    element.addEventListener('tap-item-foo', stub);
+    element.addEventListener('tap-item', tapped);
+    flush();
+    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 = [];
+    flush();
+    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(() => {
+      element.items = [
+        {name: 'item one', id: 'foo'},
+        {name: 'item two', id: 'bar'},
+      ];
+      flush();
+    });
+
+    test('down', () => {
+      const stub = sinon.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 = sinon.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);
+
+      const el = element.$.cursor.target.querySelector(':not([hidden]) a');
+      const stub = sinon.stub(el, 'click');
+      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      assert.isTrue(stub.called);
+    });
+  });
+});
+
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
deleted file mode 100644
index 804eb16..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const RESTORED_MESSAGE = 'Content restored from a previous edit.';
-const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
-
-/**
- * @extends Polymer.Element
- */
-class GrEditableContent extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-editable-content'; }
-  /**
-   * Fired when the save button is pressed.
-   *
-   * @event editable-content-save
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event editable-content-cancel
-   */
-
-  /**
-   * Fired when content is restored from storage.
-   *
-   * @event show-alert
-   */
-
-  static get properties() {
-    return {
-      content: {
-        notify: true,
-        type: String,
-        observer: '_contentChanged',
-      },
-      disabled: {
-        reflectToAttribute: true,
-        type: Boolean,
-        value: false,
-      },
-      editing: {
-        observer: '_editingChanged',
-        type: Boolean,
-        value: false,
-      },
-      removeZeroWidthSpace: Boolean,
-      // If no storage key is provided, content is not stored.
-      storageKey: String,
-      _saveDisabled: {
-        computed: '_computeSaveDisabled(disabled, content, _newContent)',
-        type: Boolean,
-        value: true,
-      },
-      _newContent: {
-        type: String,
-        observer: '_newContentChanged',
-      },
-    };
-  }
-
-  _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.shadowRoot.querySelector('iron-autogrow-textarea').textarea.focus();
-  }
-
-  _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;
-    }
-
-    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 || '';
-    }
-
-    // TODO(wyatta) switch linkify sequence, see issue 5526.
-    this._newContent = this.removeZeroWidthSpace ?
-      content.replace(/^R=\u200B/gm, 'R=') :
-      content;
-  }
-
-  _computeSaveDisabled(disabled, 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.
-  }
-
-  _handleCancel(e) {
-    e.preventDefault();
-    this.editing = false;
-    this.dispatchEvent(new CustomEvent('editable-content-cancel', {
-      composed: true, bubbles: true,
-    }));
-  }
-}
-
-customElements.define(GrEditableContent.is, GrEditableContent);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
new file mode 100644
index 0000000..90aaa9f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -0,0 +1,203 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/shared-styles';
+import '../gr-storage/gr-storage';
+import '../gr-button/gr-button';
+import {GrStorage} from '../gr-storage/gr-storage';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-editable-content_html';
+
+const RESTORED_MESSAGE = 'Content restored from a previous edit.';
+const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-editable-content': GrEditableContent;
+  }
+}
+
+export interface GrEditableContent {
+  $: {
+    storage: GrStorage;
+  };
+}
+
+@customElement('gr-editable-content')
+export class GrEditableContent extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the save button is pressed.
+   *
+   * @event editable-content-save
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event editable-content-cancel
+   */
+
+  /**
+   * Fired when content is restored from storage.
+   *
+   * @event show-alert
+   */
+
+  @property({type: String, notify: true, observer: '_contentChanged'})
+  content?: string;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  @property({type: Boolean, observer: '_editingChanged'})
+  editing = false;
+
+  @property({type: Boolean})
+  removeZeroWidthSpace?: boolean;
+
+  // If no storage key is provided, content is not stored.
+  @property({type: String})
+  storageKey?: string;
+
+  @property({
+    type: Boolean,
+    computed: '_computeSaveDisabled(disabled, content, _newContent)',
+  })
+  _saveDisabled!: boolean;
+
+  @property({type: String, observer: '_newContentChanged'})
+  _newContent?: string;
+
+  _contentChanged() {
+    /* 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.shadowRoot!.querySelector('iron-autogrow-textarea')!.textarea.focus();
+  }
+
+  _newContentChanged(newContent: string) {
+    if (!this.storageKey) return;
+    const storageKey = this.storageKey;
+
+    this.debounce(
+      'store',
+      () => {
+        if (newContent.length) {
+          this.$.storage.setEditableContentItem(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(storageKey);
+        }
+      },
+      STORAGE_DEBOUNCE_INTERVAL_MS
+    );
+  }
+
+  _editingChanged(editing: boolean) {
+    // 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?.message) {
+        content = storedContent.message;
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: RESTORED_MESSAGE},
+            bubbles: true,
+            composed: true,
+          })
+        );
+      }
+    }
+    if (!content) {
+      content = this.content || '';
+    }
+
+    // TODO(wyatta) switch linkify sequence, see issue 5526.
+    this._newContent = this.removeZeroWidthSpace
+      ? content.replace(/^R=\u200B/gm, 'R=')
+      : content;
+  }
+
+  _computeSaveDisabled(
+    disabled?: boolean,
+    content?: string,
+    newContent?: string
+  ): boolean {
+    return disabled || !newContent || content === newContent;
+  }
+
+  _handleSave(e: Event) {
+    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.
+  }
+
+  _handleCancel(e: Event) {
+    e.preventDefault();
+    this.editing = false;
+    this.dispatchEvent(
+      new CustomEvent('editable-content-cancel', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+}
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
deleted file mode 100644
index 24eb0b0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
new file mode 100644
index 0000000..81a2c2f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -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';
+
+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
deleted file mode 100644
index c50920e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ /dev/null
@@ -1,166 +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">
-<meta charset="utf-8">
-<title>gr-editable-content</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>
-<!-- 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>
-    <gr-editable-content></gr-editable-content>
-  </template>
-</test-fixture>
-
-<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.content = 'current content';
-      element.editing = true;
-    });
-
-    test('save button is disabled initially', () => {
-      assert.isTrue(element.shadowRoot
-          .querySelector('gr-button[primary]').disabled);
-    });
-
-    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-content/gr-editable-content_test.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
new file mode 100644
index 0000000..129fda8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
@@ -0,0 +1,145 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-editable-content.js';
+
+const basicFixture = fixtureFromElement('gr-editable-content');
+
+suite('gr-editable-content tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('save event', () => {
+    element.content = '';
+    element._newContent = 'foo';
+    const handler = sinon.spy();
+    element.addEventListener('editable-content-save', handler);
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button[primary]'));
+
+    assert.isTrue(handler.called);
+    assert.equal(handler.lastCall.args[0].detail.content, 'foo');
+  });
+
+  test('cancel event', () => {
+    const handler = sinon.spy();
+    element.addEventListener('editable-content-cancel', handler);
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button:not([primary])'));
+
+    assert.isTrue(handler.called);
+  });
+
+  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.content = 'current content';
+      element.editing = true;
+    });
+
+    test('save button is disabled initially', () => {
+      assert.isTrue(element.shadowRoot
+          .querySelector('gr-button[primary]').disabled);
+    });
+
+    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 = sinon.spy(element, 'dispatchEvent');
+    });
+
+    test('editing toggled to true, has stored data', () => {
+      sinon.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', () => {
+      sinon.stub(element.$.storage, 'getEditableContentItem')
+          .returns({});
+      element.editing = true;
+
+      assert.equal(element._newContent, 'current content');
+      assert.isFalse(dispatchSpy.called);
+    });
+
+    test('edits are cached', () => {
+      const storeStub =
+          sinon.stub(element.$.storage, 'setEditableContentItem');
+      const eraseStub =
+          sinon.stub(element.$.storage, 'eraseEditableContentItem');
+      element.editing = true;
+
+      element._newContent = 'new content';
+      flush();
+      element.flushDebouncer('store');
+
+      assert.isTrue(storeStub.called);
+      assert.deepEqual(
+          [element.storageKey, element._newContent],
+          storeStub.lastCall.args);
+
+      element._newContent = '';
+      flush();
+      element.flushDebouncer('store');
+
+      assert.isTrue(eraseStub.called);
+      assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
+    });
+  });
+});
+
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
deleted file mode 100644
index 8669f03..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ /dev/null
@@ -1,219 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-
-/**
- * @extends Polymer.Element
- */
-class GrEditableLabel extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-editable-label'; }
-  /**
-   * Fired when the value is changed.
-   *
-   * @event changed
-   */
-
-  static get properties() {
-    return {
-      labelText: String,
-      editing: {
-        type: Boolean,
-        value: false,
-      },
-      value: {
-        type: String,
-        notify: true,
-        value: '',
-        observer: '_updateTitle',
-      },
-      placeholder: {
-        type: String,
-        value: '',
-      },
-      readOnly: {
-        type: Boolean,
-        value: false,
-      },
-      uppercase: {
-        type: Boolean,
-        reflectToAttribute: true,
-        value: false,
-      },
-      maxLength: Number,
-      _inputText: String,
-      // This is used to push the iron-input element up on the page, so
-      // the input is placed in approximately the same position as the
-      // trigger.
-      _verticalOffset: {
-        type: Number,
-        readOnly: true,
-        value: -30,
-      },
-    };
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('tabindex', '0');
-  }
-
-  get keyBindings() {
-    return {
-      enter: '_handleEnter',
-      esc: '_handleEsc',
-    };
-  }
-
-  _usePlaceholder(value, placeholder) {
-    return (!value || !value.length) && placeholder;
-  }
-
-  _computeLabel(value, placeholder) {
-    if (this._usePlaceholder(value, placeholder)) {
-      return placeholder;
-    }
-    return value;
-  }
-
-  _showDropdown() {
-    if (this.readOnly || this.editing) { return; }
-    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(...args) {
-    this.$.dropdown.open();
-    this._inputText = this.value;
-    this.editing = true;
-
-    return new Promise(resolve => {
-      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);
-  }
-
-  _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,
-    }));
-  }
-
-  _cancel() {
-    if (!this.editing) { return; }
-    this.$.dropdown.close();
-    this.editing = false;
-    this._inputText = this.value;
-  }
-
-  get _nativeInput() {
-    // In Polymer 2, the namespace of nativeInput
-    // changed from input to nativeInput
-    return this.$.input.$.nativeInput || this.$.input.$.input;
-  }
-
-  _handleEnter(e) {
-    e = this.getKeyboardEvent(e);
-    const target = 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();
-    }
-  }
-
-  _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));
-  }
-}
-
-customElements.define(GrEditableLabel.is, GrEditableLabel);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
new file mode 100644
index 0000000..9e1a5bf
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -0,0 +1,226 @@
+/**
+ * @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 '@polymer/iron-dropdown/iron-dropdown';
+import '@polymer/paper-input/paper-input';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-editable-label_html';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {PaperInputElementExt} from '../../../types/types';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-editable-label': GrEditableLabel;
+  }
+}
+
+export interface GrEditableLabel {
+  $: {
+    input: PaperInputElementExt;
+    dropdown: IronDropdownElement;
+  };
+}
+
+@customElement('gr-editable-label')
+export class GrEditableLabel extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the value is changed.
+   *
+   * @event changed
+   */
+
+  @property({type: String})
+  labelText?: string;
+
+  @property({type: Boolean})
+  editing = false;
+
+  @property({type: String, notify: true, observer: '_updateTitle'})
+  value = '';
+
+  @property({type: String})
+  placeholder = '';
+
+  @property({type: Boolean})
+  readOnly = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  uppercase = false;
+
+  @property({type: Number})
+  maxLength?: number;
+
+  @property({type: String})
+  _inputText?: string;
+
+  // This is used to push the iron-input element up on the page, so
+  // the input is placed in approximately the same position as the
+  // trigger.
+  @property({type: Number})
+  readonly _verticalOffset = -30;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('tabindex', '0');
+  }
+
+  get keyBindings() {
+    return {
+      enter: '_handleEnter',
+      esc: '_handleEsc',
+    };
+  }
+
+  _usePlaceholder(value?: string, placeholder?: string) {
+    return (!value || !value.length) && placeholder;
+  }
+
+  _computeLabel(value?: string, placeholder?: string): string {
+    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);
+    });
+  }
+
+  open() {
+    return this._open().then(() => {
+      this._nativeInput.focus();
+    });
+  }
+
+  _open() {
+    this.$.dropdown.open();
+    this._inputText = this.value;
+    this.editing = true;
+
+    return new Promise(resolve => {
+      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: () => void) {
+    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';
+  }
+
+  _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,
+      })
+    );
+  }
+
+  _cancel() {
+    if (!this.editing) {
+      return;
+    }
+    this.$.dropdown.close();
+    this.editing = false;
+    this._inputText = this.value;
+  }
+
+  get _nativeInput(): HTMLInputElement {
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return (this.$.input.$.nativeInput ||
+      this.$.input.inputElement) as HTMLInputElement;
+  }
+
+  _handleEnter(e: CustomKeyboardEvent) {
+    e = this.getKeyboardEvent(e);
+    const target = (dom(e) as EventApi).rootTarget;
+    if (target === this._nativeInput) {
+      e.preventDefault();
+      this._save();
+    }
+  }
+
+  _handleEsc(e: CustomKeyboardEvent) {
+    e = this.getKeyboardEvent(e);
+    const target = (dom(e) as EventApi).rootTarget;
+    if (target === this._nativeInput) {
+      e.preventDefault();
+      this._cancel();
+    }
+  }
+
+  _computeLabelClass(readOnly?: boolean, value?: string, placeholder?: string) {
+    const classes = [];
+    if (!readOnly) {
+      classes.push('editable');
+    }
+    if (this._usePlaceholder(value, placeholder)) {
+      classes.push('placeholder');
+    }
+    return classes.join(' ');
+  }
+
+  _updateTitle(value?: string) {
+    this.setAttribute('title', this._computeLabel(value, this.placeholder));
+  }
+}
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
deleted file mode 100644
index a226e30..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
new file mode 100644
index 0000000..5e36166
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 5673194..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ /dev/null
@@ -1,253 +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">
-<meta charset="utf-8">
-<title>gr-editable-label</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>
-<!-- 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>
-    <gr-editable-label
-        value="value text"
-        placeholder="label text"></gr-editable-label>
-  </template>
-</test-fixture>
-
-<test-fixture id="no-placeholder">
-  <template>
-    <gr-editable-label value=""></gr-editable-label>
-  </template>
-</test-fixture>
-
-<test-fixture id="read-only">
-  <template>
-    <gr-editable-label
-        read-only
-        value="value text"
-        placeholder="label text"></gr-editable-label>
-  </template>
-</test-fixture>
-
-<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');
-
-    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();
-  });
-
-  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('title with placeholder', done => {
-    assert.equal(element.title, 'value text');
-    element.value = '';
-
-    element.async(() => {
-      assert.equal(element.title, 'label text');
-      done();
-    });
-  });
-
-  test('title without placeholder', done => {
-    assert.equal(elementNoPlaceholder.title, '');
-    element.value = 'value text';
-
-    element.async(() => {
-      assert.equal(element.title, 'value text');
-      done();
-    });
-  });
-
-  test('edit value', 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);
-      done();
-    });
-
-    // 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);
-      done();
-    });
-
-    // Press enter:
-    MockInteractions.tap(element.$.saveBtn, 13);
-  });
-
-  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);
-      done();
-    });
-
-    // 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);
-      done();
-    });
-
-    // Press escape:
-    MockInteractions.tap(element.$.cancelBtn);
-  });
-
-  suite('gr-editable-label read-only tests', () => {
-    let element;
-    let label;
-
-    setup(() => {
-      element = fixture('read-only');
-      label = element.shadowRoot
-          .querySelector('label');
-    });
-
-    test('disallows edit when read-only', () => {
-      // The dropdown is closed.
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(label);
-
-      flush$0();
-
-      // The dropdown is still closed.
-      assert.isFalse(element.$.dropdown.opened);
-    });
-
-    test('label is not marked as editable', () => {
-      assert.isFalse(label.classList.contains('editable'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
new file mode 100644
index 0000000..d8f085e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.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 '../../../test/common-test-setup-karma.js';
+import './gr-editable-label.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-editable-label
+        value="value text"
+        placeholder="label text"></gr-editable-label>
+`);
+
+const noPlaceholderFixture = fixtureFromTemplate(html`
+<gr-editable-label value=""></gr-editable-label>
+`);
+
+const readOnlyFixture = fixtureFromTemplate(html`
+<gr-editable-label
+        read-only
+        value="value text"
+        placeholder="label text"></gr-editable-label>
+`);
+
+suite('gr-editable-label tests', () => {
+  let element;
+  let elementNoPlaceholder;
+  let input;
+  let label;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    elementNoPlaceholder = noPlaceholderFixture.instantiate();
+    label = element.shadowRoot.querySelector('label');
+
+    await flush();
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    input = element.$.input.$.nativeInput || element.$.input.inputElement;
+  });
+
+  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 = sinon.spy(input, 'focus');
+    const showSpy = sinon.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('title with placeholder', () => {
+    assert.equal(element.title, 'value text');
+    element.value = '';
+
+    flush();
+    assert.equal(element.title, 'label text');
+  });
+
+  test('title without placeholder', () => {
+    assert.equal(elementNoPlaceholder.title, '');
+    element.value = 'value text';
+
+    flush();
+    assert.equal(element.title, 'value text');
+  });
+
+  test('edit value', async () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+    flush();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element._inputText = 'new text';
+    // Press enter:
+    MockInteractions.keyDownOn(input, 13);
+    flush();
+
+    assert.isTrue(editedSpy.called);
+    assert.equal(input.value, 'new text');
+    assert.isFalse(element.editing);
+  });
+
+  test('save button', () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+    flush();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element._inputText = 'new text';
+    // Press enter:
+    MockInteractions.tap(element.$.saveBtn, 13);
+    flush();
+
+    assert.isTrue(editedSpy.called);
+    assert.equal(input.value, 'new text');
+    assert.isFalse(element.editing);
+  });
+
+  test('edit and then escape key', () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+    flush();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element._inputText = 'new text';
+    // Press escape:
+    MockInteractions.keyDownOn(input, 27);
+    flush();
+
+    assert.isFalse(editedSpy.called);
+    // Text changes should be discarded.
+    assert.equal(input.value, 'value text');
+    assert.isFalse(element.editing);
+  });
+
+  test('cancel button', () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+    flush();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element._inputText = 'new text';
+    // Press escape:
+    MockInteractions.tap(element.$.cancelBtn);
+    flush();
+
+    assert.isFalse(editedSpy.called);
+    // Text changes should be discarded.
+    assert.equal(input.value, 'value text');
+    assert.isFalse(element.editing);
+  });
+
+  suite('gr-editable-label read-only tests', () => {
+    let element;
+    let label;
+
+    setup(() => {
+      element = readOnlyFixture.instantiate();
+      label = element.shadowRoot
+          .querySelector('label');
+    });
+
+    test('disallows edit when read-only', () => {
+      // The dropdown is closed.
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(label);
+
+      flush();
+
+      // The dropdown is still closed.
+      assert.isFalse(element.$.dropdown.opened);
+    });
+
+    test('label is not marked as editable', () => {
+      assert.isFalse(label.classList.contains('editable'));
+    });
+  });
+});
+
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
deleted file mode 100644
index cfe4c4f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {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
deleted file mode 100644
index 7705874..0000000
--- a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js
+++ /dev/null
@@ -1,134 +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.
- */
-
-/**
- * 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() {
-    /**
-     * Shared events map from name to the listeners.
-     *
-     * @type {!Object<string, Array<eventCallback>>}
-     */
-    this._listenersMap = new Map();
-  }
-
-  /**
-   * 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
deleted file mode 100644
index 74936ad..0000000
--- a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
+++ /dev/null
@@ -1,152 +0,0 @@
-<!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-api-interface</title>
-
-<script src="../../../node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-js-api-interface></gr-js-api-interface>
-  </template>
-</test-fixture>
-
-<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(() => {
-      fixture('basic');
-      pluginApi.removeAllListeners();
-    });
-
-    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');
-    });
-
-    test('listen on events from core', done => {
-      const eventName = 'test-plugin-event';
-      pluginApi.on(eventName, e => {
-        assert.equal(e.value, 'test');
-        done();
-      });
-
-      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();
-        });
-      }, '0.1',
-      'http://test.com/plugins/testA/static/testA.js');
-
-      pluginApi.install(plugin => {
-        pluginApi.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);
-    });
-  });
-});
-</script>
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
deleted file mode 100644
index 0d19f00..0000000
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
+++ /dev/null
@@ -1,244 +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.
- */
-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-fixed-panel_html.js';
-
-/** @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',
-      },
-      keepOnScroll: {
-        type: Boolean,
-        value: false,
-      },
-      _isMeasured: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Initial offset from the top of the document, in pixels.
-       */
-      _topInitial: Number,
-
-      /**
-       * Current offset from the top of the window, in pixels.
-       */
-      _topLast: Number,
-
-      _headerHeight: Number,
-      _headerFloating: {
-        type: Boolean,
-        value: false,
-      },
-      _observer: {
-        type: Object,
-        value: null,
-      },
-      /**
-       * 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,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateFloatingHeight(floatingDisabled, _isMeasured, _headerHeight)',
-    ];
-  }
-
-  _updateFloatingHeight(floatingDisabled, isMeasured, headerHeight) {
-    if ([
-      floatingDisabled,
-      isMeasured,
-      headerHeight,
-    ].some(arg => arg === undefined)) {
-      return;
-    }
-    this.floatingHeight =
-        (!floatingDisabled && isMeasured) ? headerHeight : 0;
-  }
-
-  /** @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});
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'scroll', '_updateOnScroll');
-    this.unlisten(window, 'resize', 'update');
-    if (this._observer) {
-      this._observer.disconnect();
-    }
-  }
-
-  _readyForMeasureObserver(readyForMeasure) {
-    if (readyForMeasure) {
-      this.update();
-    }
-  }
-
-  _computeHeaderClass(headerFloating, topLast) {
-    const fixedAtTop = this.keepOnScroll && topLast === 0;
-    return [
-      headerFloating ? 'floating' : '',
-      fixedAtTop ? 'fixedAtTop' : '',
-    ].join(' ');
-  }
-
-  unfloat() {
-    if (this.floatingDisabled) {
-      return;
-    }
-    this.$.header.style.top = '';
-    this._headerFloating = false;
-    this.updateStyles({'--header-height': ''});
-  }
-
-  update() {
-    this.debounce('update', () => {
-      this._updateDebounced();
-    }, 100);
-  }
-
-  _updateOnScroll() {
-    this.debounce('update', () => {
-      this._updateDebounced();
-    });
-  }
-
-  _updateDebounced() {
-    if (this.floatingDisabled) {
-      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 {
-        header.style.top = newTop + 'px';
-      }
-      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;
-  }
-
-  _isFloatingNeeded() {
-    return this.keepOnScroll ||
-      document.body.scrollWidth > document.body.clientWidth;
-  }
-
-  _maybeFloatHeader() {
-    if (!this._isFloatingNeeded()) {
-      return;
-    }
-    this._measure();
-    if (this._isMeasured) {
-      this._floatHeader();
-    }
-  }
-
-  _floatHeader() {
-    this.updateStyles({'--header-height': this._headerHeight + 'px'});
-    this._headerFloating = true;
-  }
-}
-
-customElements.define(GrFixedPanel.is, GrFixedPanel);
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js
deleted file mode 100644
index 61e8b24..0000000
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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
deleted file mode 100644
index ef31382..0000000
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
+++ /dev/null
@@ -1,124 +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">
-<meta charset="utf-8">
-<title>gr-fixed-panel</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>
-<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>
-    <gr-fixed-panel>
-      <div style="height: 100px"></div>
-    </gr-fixed-panel>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
deleted file mode 100644
index 139e09c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
+++ /dev/null
@@ -1,309 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-// eslint-disable-next-line no-unused-vars
-const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
-const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
-
-/** @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',
-      },
-      config: Object,
-      noTrailingMargin: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_contentOrConfigChanged(content, config)',
-    ];
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    if (this.noTrailingMargin) {
-      this.classList.add('noTrailingMargin');
-    }
-  }
-
-  _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);
-
-    // 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;
-      }
-
-      if (this._isCodeMarkLine(lines[i])) {
-        // handle multi-line code
-        let nextI = i+1;
-        while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
-          nextI++;
-        }
-
-        if (this._isCodeMarkLine(lines[nextI])) {
-          result.push({
-            type: 'code',
-            text: lines.slice(i+1, nextI).join('\n'),
-          });
-          i = nextI;
-          continue;
-        }
-
-        // otherwise treat it as regular line and continue
-        // check for other cases
-      }
-
-      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;
-      }
-    }
-
-    return result;
-  }
-
-  /**
-   * 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;
-
-    for (let i = 0; i < lines.length; i++) {
-      line = lines[i];
-      line = line.substring(1).trim();
-      block.items.push(line);
-    }
-    return block;
-  }
-
-  _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);
-  }
-
-  _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;
-      }
-
-      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 === '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;
-      }
-    });
-  }
-}
-
-customElements.define(GrFormattedText.is, GrFormattedText);
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
new file mode 100644
index 0000000..a6c7b92
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -0,0 +1,315 @@
+/**
+ * @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 '../gr-linked-text/gr-linked-text';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-formatted-text_html';
+import {CommentLinks} from '../../../types/common';
+
+const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
+
+interface Block {
+  type: string;
+  text?: string;
+  blocks?: Block[];
+  items?: string[];
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-formatted-text': GrFormattedText;
+  }
+}
+
+export interface GrFormattedText {
+  $: {
+    container: HTMLElement;
+  };
+}
+
+@customElement('gr-formatted-text')
+export class GrFormattedText extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, observer: '_contentChanged'})
+  content?: string;
+
+  @property({type: Object})
+  config?: CommentLinks;
+
+  @property({type: Boolean})
+  noTrailingMargin = false;
+
+  static get observers() {
+    return ['_contentOrConfigChanged(content, config)'];
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    if (this.noTrailingMargin) {
+      this.classList.add('noTrailingMargin');
+    }
+  }
+
+  _contentChanged(content: string) {
+    // 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?: string) {
+    const container = this.$.container;
+
+    // Remove existing content.
+    while (container.firstChild) {
+      container.removeChild(container.firstChild);
+    }
+
+    // Add new content.
+    for (const node of this._computeNodes(this._computeBlocks(content))) {
+      if (node) 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 following 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.
+   */
+  _computeBlocks(content?: string): Block[] {
+    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;
+      }
+
+      if (this._isCodeMarkLine(lines[i])) {
+        // handle multi-line code
+        let nextI = i + 1;
+        while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
+          nextI++;
+        }
+
+        if (this._isCodeMarkLine(lines[nextI])) {
+          result.push({
+            type: 'code',
+            text: lines.slice(i + 1, nextI).join('\n'),
+          });
+          i = nextI;
+          continue;
+        }
+
+        // otherwise treat it as regular line and continue
+        // check for other cases
+      }
+
+      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;
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * 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 lines The block containing the list.
+   */
+  _makeList(lines: string[]) {
+    const items = [];
+    for (let i = 0; i < lines.length; i++) {
+      let line = lines[i];
+      line = line.substring(1).trim();
+      items.push(line);
+    }
+    return {type: 'list', items};
+  }
+
+  _isRegularLine(line: string) {
+    // 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)
+    );
+  }
+
+  _isQuote(line: string) {
+    return line && (line.startsWith('> ') || line.startsWith(' > '));
+  }
+
+  _isCodeMarkLine(line: string) {
+    return line && line.trim() === '```';
+  }
+
+  _isSingleLineCode(line: string) {
+    return line && CODE_MARKER_PATTERN.test(line);
+  }
+
+  _isPreFormat(line: string) {
+    return line && /^[ \t]/.test(line);
+  }
+
+  _isList(line: string) {
+    return line && /^[-*] /.test(line);
+  }
+
+  _makeLinkedText(content = '', isPre?: boolean) {
+    const text = document.createElement('gr-linked-text');
+    text.config = this.config;
+    text.content = content;
+    text.pre = true;
+    if (isPre) {
+      text.classList.add('pre');
+    }
+    return text;
+  }
+
+  /**
+   * Map an array of block objects to an array of DOM nodes.
+   */
+  _computeNodes(blocks: Block[]): HTMLElement[] {
+    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 || [])) {
+          if (node) 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 === 'list') {
+        const ul = document.createElement('ul');
+        const items = block.items || [];
+        for (const item of items) {
+          const li = document.createElement('li');
+          li.appendChild(this._makeLinkedText(item));
+          ul.appendChild(li);
+        }
+        return ul;
+      }
+
+      console.warn('Unrecognized type.');
+      return document.createElement('span');
+    });
+  }
+}
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
deleted file mode 100644
index 5cb8670..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
new file mode 100644
index 0000000..bc1dfe0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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);
+    }
+    code,
+    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
deleted file mode 100644
index 083eac4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
+++ /dev/null
@@ -1,426 +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">
-<meta charset="utf-8">
-<title>gr-editable-label</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-formatted-text></gr-formatted-text>
-  </template>
-</test-fixture>
-
-<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 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 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-formatted-text/gr-formatted-text_test.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
new file mode 100644
index 0000000..fd5a9ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
@@ -0,0 +1,406 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-formatted-text.js';
+
+const basicFixture = fixtureFromElement('gr-formatted-text');
+
+suite('gr-formatted-text tests', () => {
+  let element;
+
+  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);
+  }
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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 = sinon.spy(element, '_computeNodes');
+    element.content = 'some text';
+    assert.isTrue(computeNodesSpy.called);
+  });
+
+  test('_contentOrConfigChanged called with config', () => {
+    const contentStub = sinon.stub(element, '_contentChanged');
+    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
+    element.content = 'some text';
+    element.config = {};
+    assert.isTrue(contentStub.called);
+    assert.isTrue(contentConfigStub.called);
+  });
+});
+
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
deleted file mode 100644
index 0bc9cb7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
new file mode 100644
index 0000000..da2881e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -0,0 +1,324 @@
+/**
+ * @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 '@polymer/iron-icon/iron-icon';
+import '../../../styles/shared-styles';
+import '../gr-avatar/gr-avatar';
+import '../gr-button/gr-button';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-hovercard-account_html';
+import {appContext} from '../../../services/app-context';
+import {accountKey} from '../../../utils/account-util';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ServerInfo,
+  ReviewInput,
+} from '../../../types/common';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {
+  canHaveAttention,
+  getLastUpdate,
+  getReason,
+  hasAttention,
+  isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
+import {ReviewerState} from '../../../constants/constants';
+import {isRemovableReviewer} from '../../../utils/change-util';
+
+export interface GrHovercardAccount {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-hovercard-account')
+export class GrHovercardAccount extends GestureEventListeners(
+  hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  account!: AccountInfo;
+
+  @property({type: Object})
+  _selfAccount?: AccountInfo;
+
+  /**
+   * Optional ChangeInfo object, typically comes from the change page or
+   * from a row in a list of search results. This is needed for some change
+   * related features like adding the user as a reviewer.
+   */
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  /**
+   * Explains which labels the user can vote on and which score they can
+   * give.
+   */
+  @property({type: String})
+  voteableText?: string;
+
+  /**
+   * Should attention set related features be shown in the component? Note
+   * that the information whether the user is in the attention set or not is
+   * part of the ChangeInfo object in the change property.
+   */
+  @property({type: Boolean})
+  highlightAttention = false;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  reporting: ReportingService;
+
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+    this.$.restAPI.getAccount().then(account => {
+      this._selfAccount = account;
+    });
+  }
+
+  _computeText(account?: AccountInfo, selfAccount?: AccountInfo) {
+    if (!account || !selfAccount) return '';
+    return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
+  }
+
+  get isAttentionEnabled() {
+    return (
+      isAttentionSetEnabled(this._config) &&
+      !!this.highlightAttention &&
+      !!this.change &&
+      canHaveAttention(this.account)
+    );
+  }
+
+  get hasUserAttention() {
+    return hasAttention(this._config, this.account, this.change);
+  }
+
+  _computeReason(change?: ChangeInfo) {
+    return getReason(this.account, change);
+  }
+
+  _computeLastUpdate(change?: ChangeInfo) {
+    return getLastUpdate(this.account, change);
+  }
+
+  _showReviewerOrCCActions(account?: AccountInfo, change?: ChangeInfo) {
+    return !!this._selfAccount && isRemovableReviewer(change, account);
+  }
+
+  _getReviewerState(account: AccountInfo, change: ChangeInfo) {
+    if (
+      change.reviewers[ReviewerState.REVIEWER]?.some(
+        (reviewer: AccountInfo) => {
+          return reviewer._account_id === account._account_id;
+        }
+      )
+    ) {
+      return ReviewerState.REVIEWER;
+    }
+    return ReviewerState.CC;
+  }
+
+  _computeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+    if (!change || !account) return '';
+    return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+      ? 'Reviewer'
+      : 'CC';
+  }
+
+  _computeChangeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+    if (!change || !account) return '';
+    return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+      ? 'Move Reviewer to CC'
+      : 'Move CC to Reviewer';
+  }
+
+  _handleChangeReviewerOrCCStatus() {
+    if (!this.change) throw new Error('expected change object to be present');
+    // accountKey() throws an error if _account_id & email is not found, which
+    // we want to check before showing reloading toast
+    const _accountKey = accountKey(this.account);
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Reloading page...',
+    });
+    const reviewInput: Partial<ReviewInput> = {};
+    reviewInput.reviewers = [
+      {
+        reviewer: _accountKey,
+        state:
+          this._getReviewerState(this.account, this.change) === ReviewerState.CC
+            ? ReviewerState.REVIEWER
+            : ReviewerState.CC,
+      },
+    ];
+
+    this.$.restAPI
+      .saveChangeReview(this.change._number, 'current', reviewInput)
+      .then(response => {
+        if (!response || !response.ok) {
+          throw new Error(
+            'something went wrong when toggling' +
+              this._getReviewerState(this.account, this.change!)
+          );
+        }
+        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+      });
+  }
+
+  _handleRemoveReviewerOrCC() {
+    if (!this.change || !(this.account?._account_id || this.account?.email))
+      throw new Error('Missing change or account.');
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Reloading page...',
+    });
+    this.$.restAPI
+      .removeChangeReviewer(
+        this.change._number,
+        (this.account?._account_id || this.account?.email)!
+      )
+      .then((response: Response | undefined) => {
+        if (!response || !response.ok) {
+          throw new Error('something went wrong when removing user');
+        }
+        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+        return response;
+      });
+  }
+
+  _computeShowLabelNeedsAttention() {
+    return this.isAttentionEnabled && this.hasUserAttention;
+  }
+
+  _computeShowActionAddToAttentionSet() {
+    return (
+      this._selfAccount && this.isAttentionEnabled && !this.hasUserAttention
+    );
+  }
+
+  _computeShowActionRemoveFromAttentionSet() {
+    return (
+      this._selfAccount && this.isAttentionEnabled && this.hasUserAttention
+    );
+  }
+
+  _handleClickAddToAttentionSet() {
+    if (!this.change || !this.account._account_id) return;
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
+
+    // We are deliberately updating the UI before making the API call. It is a
+    // risk that we are taking to achieve a better UX for 99.9% of the cases.
+    const selfName = getDisplayName(this._config, this._selfAccount);
+    const reason = `Added by ${selfName} using the hovercard menu`;
+    if (!this.change.attention_set) this.change.attention_set = {};
+    this.change.attention_set[this.account._account_id] = {
+      account: this.account,
+      reason,
+    };
+    this.dispatchEventThroughTarget('attention-set-updated');
+
+    this.reporting.reportInteraction(
+      'attention-hovercard-add',
+      this._reportingDetails()
+    );
+    this.$.restAPI
+      .addToAttentionSet(this.change._number, this.account._account_id, reason)
+      .then(() => {
+        this.dispatchEventThroughTarget('hide-alert');
+      });
+    this.hide();
+  }
+
+  _handleClickRemoveFromAttentionSet() {
+    if (!this.change || !this.account._account_id) return;
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
+
+    // We are deliberately updating the UI before making the API call. It is a
+    // risk that we are taking to achieve a better UX for 99.9% of the cases.
+    const selfName = getDisplayName(this._config, this._selfAccount);
+    const reason = `Removed by ${selfName} using the hovercard menu`;
+    if (this.change.attention_set)
+      delete this.change.attention_set[this.account._account_id];
+    this.dispatchEventThroughTarget('attention-set-updated');
+
+    this.reporting.reportInteraction(
+      'attention-hovercard-remove',
+      this._reportingDetails()
+    );
+    this.$.restAPI
+      .removeFromAttentionSet(
+        this.change._number,
+        this.account._account_id,
+        reason
+      )
+      .then(() => {
+        this.dispatchEventThroughTarget('hide-alert');
+      });
+    this.hide();
+  }
+
+  _reportingDetails() {
+    const targetId = this.account._account_id;
+    const ownerId =
+      (this.change && this.change.owner && this.change.owner._account_id) || -1;
+    const selfId = (this._selfAccount && this._selfAccount._account_id) || -1;
+    const reviewers =
+      this.change && this.change.reviewers && this.change.reviewers.REVIEWER
+        ? [...this.change.reviewers.REVIEWER]
+        : [];
+    const reviewerIds = reviewers
+      .map(r => r._account_id)
+      .filter(rId => rId !== ownerId);
+    return {
+      actionByOwner: selfId === ownerId,
+      actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
+      targetIsOwner: targetId === ownerId,
+      targetIsReviewer: reviewerIds.includes(targetId),
+      targetIsSelf: targetId === selfId,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-hovercard-account': 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
deleted file mode 100644
index 8d14ff4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../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_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
new file mode 100644
index 0000000..1d437fb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -0,0 +1,200 @@
+/**
+ * @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';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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);
+    }
+    .action {
+      border-top: 1px solid var(--border-color);
+      padding: var(--spacing-s) var(--spacing-l);
+      --gr-button: {
+        padding: var(--spacing-s) var(--spacing-m);
+      }
+    }
+    .attention {
+      background-color: var(--emphasis-color);
+    }
+    .attention a {
+      text-decoration: none;
+    }
+    iron-icon {
+      vertical-align: top;
+    }
+    .status iron-icon {
+      width: 14px;
+      height: 14px;
+      position: relative;
+      top: 2px;
+    }
+    iron-icon.attentionIcon {
+      width: 14px;
+      height: 14px;
+      position: relative;
+      top: 3px;
+    }
+    .reason {
+      padding-top: var(--spacing-s);
+    }
+  </style>
+  <div id="container" role="tooltip" tabindex="-1">
+    <template is="dom-if" if="[[_isShowing]]">
+      <div class="top">
+        <div class="avatar">
+          <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
+        </div>
+        <div class="account">
+          <h3 class="name heading-3">[[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>
+      <template
+        is="dom-if"
+        if="[[_computeShowLabelNeedsAttention(_config, highlightAttention, account, change)]]"
+      >
+        <div class="attention">
+          <div>
+            <iron-icon
+              class="attentionIcon"
+              icon="gr-icons:attention"
+            ></iron-icon>
+            <span>
+              [[_computeText(account, _selfAccount)]] turn to take action.
+            </span>
+            <a
+              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:bug"
+                title="report a problem"
+              ></iron-icon>
+            </a>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:help-outline"
+                title="read documentation"
+              ></iron-icon>
+            </a>
+          </div>
+          <div class="reason">
+            <span class="title">Reason:</span>
+            <span class="value">[[_computeReason(change)]]</span>
+            <template is="dom-if" if="[[_computeLastUpdate(change)]]">
+              (<gr-date-formatter
+                has-tooltip
+                date-str="[[_computeLastUpdate(change)]]"
+              ></gr-date-formatter
+              >)
+            </template>
+          </div>
+        </div>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeShowActionAddToAttentionSet(_config, highlightAttention, account, change)]]"
+      >
+        <div class="action">
+          <gr-button
+            class="addToAttentionSet"
+            link=""
+            no-uppercase=""
+            on-click="_handleClickAddToAttentionSet"
+          >
+            Add to attention set
+          </gr-button>
+        </div>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeShowActionRemoveFromAttentionSet(_config, highlightAttention, account, change)]]"
+      >
+        <div class="action">
+          <gr-button
+            class="removeFromAttentionSet"
+            link=""
+            no-uppercase=""
+            on-click="_handleClickRemoveFromAttentionSet"
+          >
+            Remove from attention set
+          </gr-button>
+        </div>
+      </template>
+      <template is="dom-if" if="[[_showReviewerOrCCActions(account, change)]]">
+        <div class="action">
+          <gr-button
+            class="removeReviewerOrCC"
+            link=""
+            no-uppercase=""
+            on-click="_handleRemoveReviewerOrCC"
+          >
+            Remove [[_computeReviewerOrCCText(account, change)]]
+          </gr-button>
+        </div>
+        <div class="action">
+          <gr-button
+            class="changeReviewerOrCC"
+            link=""
+            no-uppercase=""
+            on-click="_handleChangeReviewerOrCCStatus"
+          >
+            [[_computeChangeReviewerOrCCText(account, change)]]
+          </gr-button>
+        </div>
+      </template>
+    </template>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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
deleted file mode 100644
index be0f2b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html
+++ /dev/null
@@ -1,81 +0,0 @@
-<!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-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
new file mode 100644
index 0000000..b09f0ce
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -0,0 +1,271 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-hovercard-account.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {ReviewerState} from '../../../constants/constants.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-hovercard-account class="hovered"></gr-hovercard-account>
+`);
+
+suite('gr-hovercard-account tests', () => {
+  let element;
+
+  const ACCOUNT = {
+    email: 'kermit@gmail.com',
+    username: 'kermit',
+    name: 'Kermit The Frog',
+    _account_id: '31415926535',
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    sinon.stub(element.$.restAPI, 'getAccount').returns(
+        new Promise(resolve => { '2'; })
+    );
+
+    element._selfAccount = {...ACCOUNT};
+    element.account = {...ACCOUNT};
+    element._config = {
+      change: {enable_attention_set: true},
+    };
+    element.change = {
+      attention_set: {},
+    };
+    element.show({});
+    flush();
+  });
+
+  teardown(() => {
+    element.hide({});
+  });
+
+  test('account name is shown', () => {
+    assert.equal(element.shadowRoot.querySelector('.name').innerText,
+        'Kermit The Frog');
+  });
+
+  test('_computeLastUpdate', () => {
+    const last_update = '2019-07-17 19:39:02.000000000';
+    const change = {
+      attention_set: {
+        31415926535: {
+          last_update,
+        },
+      },
+    };
+    assert.equal(element._computeLastUpdate(change), last_update);
+  });
+
+  test('_computeText', () => {
+    let account = {_account_id: '1'};
+    const selfAccount = {_account_id: '1'};
+    assert.equal(element._computeText(account, selfAccount), 'Your');
+    account = {_account_id: '2'};
+    assert.equal(element._computeText(account, selfAccount), 'Their');
+  });
+
+  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 = {status: 'OOO', ...ACCOUNT};
+    flush();
+    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';
+    flush();
+    assert.equal(element.shadowRoot.querySelector('.voteable .value').innerText,
+        element.voteableText);
+  });
+
+  test('remove reviewer', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+    flush();
+    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Remove Reviewer');
+    MockInteractions.tap(button);
+    await flush();
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    const saveReviewStub = sinon.stub(element.$.restAPI,
+        'saveChangeReview').returns(
+        Promise.resolve({ok: true}));
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+
+    flush();
+    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move Reviewer to CC');
+    MockInteractions.tap(button);
+    await flush();
+
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    const saveReviewStub = sinon.stub(element.$.restAPI,
+        'saveChangeReview').returns(
+        Promise.resolve({ok: true}));
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+    flush();
+
+    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move CC to Reviewer');
+
+    MockInteractions.tap(button);
+    await flush();
+
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('remove cc', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+
+    flush();
+    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+
+    assert.equal(button.innerText, 'Remove CC');
+    assert.isOk(button);
+    MockInteractions.tap(button);
+
+    await flush();
+
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('add to attention set', async () => {
+    let apiResolve;
+    const apiPromise = new Promise(r => {
+      apiResolve = r;
+    });
+    sinon.stub(element.$.restAPI, 'addToAttentionSet')
+        .callsFake(() => apiPromise);
+    element.highlightAttention = true;
+    element._target = document.createElement('div');
+    flush();
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element._target.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('hide-alert', hideAlertListener);
+    element._target.addEventListener('attention-set-updated', updatedListener);
+
+    const button = element.shadowRoot.querySelector('.addToAttentionSet');
+    assert.isOk(button);
+    assert.isTrue(element._isShowing, 'hovercard is showing');
+    MockInteractions.tap(button);
+
+    assert.equal(Object.keys(element.change.attention_set).length, 1);
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+    assert.isFalse(element._isShowing, 'hovercard is hidden');
+
+    apiResolve({});
+    await flush();
+
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+
+  test('remove from attention set', async () => {
+    let apiResolve;
+    const apiPromise = new Promise(r => {
+      apiResolve = r;
+    });
+    sinon.stub(element.$.restAPI, 'removeFromAttentionSet')
+        .callsFake(() => apiPromise);
+    element.highlightAttention = true;
+    element.change = {attention_set: {31415926535: {}}};
+    element._target = document.createElement('div');
+    flush();
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element._target.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('hide-alert', hideAlertListener);
+    element._target.addEventListener('attention-set-updated', updatedListener);
+
+    const button = element.shadowRoot.querySelector('.removeFromAttentionSet');
+    assert.isOk(button);
+    assert.isTrue(element._isShowing, 'hovercard is showing');
+    MockInteractions.tap(button);
+
+    assert.equal(Object.keys(element.change.attention_set).length, 0);
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+    assert.isFalse(element._isShowing, 'hovercard is hidden');
+
+    apiResolve({});
+    await flush();
+
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+});
+
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
deleted file mode 100644
index a4d00224..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
+++ /dev/null
@@ -1,396 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
new file mode 100644
index 0000000..78b6cda
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -0,0 +1,498 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {Debouncer} from '@polymer/polymer/lib/utils/debounce';
+import {timeOut} from '@polymer/polymer/lib/utils/async';
+import {getRootElement} from '../../../scripts/rootElement';
+import {Constructor} from '../../../utils/common-util';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {property, observe} from '@polymer/decorators';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {
+  pushScrollLock,
+  removeScrollLock,
+} from '@polymer/iron-overlay-behavior/iron-scroll-manager';
+
+interface ShowAlertEventDetail {
+  message: string;
+  dismissOnNavigation?: boolean;
+}
+
+interface ReloadEventDetail {
+  clearPatchset?: boolean;
+}
+
+const HOVER_CLASS = 'hovered';
+const HIDE_CLASS = 'hide';
+
+/**
+ * How long should we wait before showing the hovercard when the user hovers
+ * over the element?
+ */
+const SHOW_DELAY_MS = 550;
+
+/**
+ * How long should we wait before hiding the hovercard when the user moves from
+ * target to the hovercard.
+ *
+ * Note: this should be lower than SHOW_DELAY_MS to avoid flickering.
+ */
+const HIDE_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)
+ *
+ * @see gr-hovercard.ts
+ *
+ * // following annotations are required for polylint
+ * @polymer
+ * @mixinFunction
+ */
+export const hovercardBehaviorMixin = dedupingMixin(
+  <T extends Constructor<PolymerElement & LegacyElementMixin>>(
+    superClass: T
+  ): T & Constructor<GrHovercardBehaviorInterface> => {
+    /**
+     * @polymer
+     * @mixinClass
+     */
+    class Mixin extends superClass {
+      @property({type: Object})
+      _target: Element | null = null;
+
+      // Determines whether or not the hovercard is visible.
+      @property({type: Boolean})
+      _isShowing = false;
+
+      // The `id` of the element that the hovercard is anchored to.
+      @property({type: String})
+      for?: string;
+
+      /**
+       * The spacing between the top of the hovercard and the element it is
+       * anchored to.
+       */
+      @property({type: Number})
+      offset = 14;
+
+      /**
+       * Positions the hovercard to the top, right, bottom, left, bottom-left,
+       * bottom-right, top-left, or top-right of its content.
+       */
+      @property({type: String})
+      position = 'right';
+
+      @property({type: Object})
+      container: HTMLElement | null = null;
+
+      /**
+       * ID for the container element.
+       */
+      @property({type: String})
+      containerId = 'gr-hovercard-container';
+
+      private _hideDebouncer: Debouncer | null = null;
+
+      private _showDebouncer: Debouncer | null = null;
+
+      private _isScheduledToShow?: boolean;
+
+      private _isScheduledToHide?: boolean;
+
+      /** @override */
+      attached() {
+        super.attached();
+        if (!this._target) {
+          this._target = this.target;
+        }
+        this.listen(this._target, 'mouseenter', 'debounceShow');
+        this.listen(this._target, 'focus', 'debounceShow');
+        this.listen(this._target, 'mouseleave', 'debounceHide');
+        this.listen(this._target, 'blur', 'debounceHide');
+
+        // when click, dismiss immediately
+        this.listen(this._target, 'click', 'hide');
+
+        // show the hovercard if mouse moves to hovercard
+        // this will cancel pending hide as well
+        this.listen(this, 'mouseenter', 'show');
+        this.listen(this, 'mouseenter', 'lock');
+        // when leave hovercard, hide it immediately
+        this.listen(this, 'mouseleave', 'hide');
+        this.listen(this, 'mouseleave', 'unlock');
+      }
+
+      detached() {
+        super.detached();
+        this.unlock();
+      }
+
+      /** @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', 'debounceShow');
+        this.unlisten(this._target, 'focus', 'debounceShow');
+        this.unlisten(this._target, 'mouseleave', 'debounceHide');
+        this.unlisten(this._target, 'blur', 'debounceHide');
+        this.unlisten(this._target, 'click', 'hide');
+      }
+
+      debounceHide() {
+        this.cancelShowDebouncer();
+        if (!this._isShowing || this._isScheduledToHide) return;
+        this._isScheduledToHide = true;
+        this._hideDebouncer = Debouncer.debounce(
+          this._hideDebouncer,
+          timeOut.after(HIDE_DELAY_MS),
+          () => {
+            // This happens when hide immediately through click or mouse leave
+            // on the hovercard
+            if (!this._isScheduledToHide) return;
+            this.hide();
+          }
+        );
+      }
+
+      cancelHideDebouncer() {
+        if (this._hideDebouncer) {
+          this._hideDebouncer.cancel();
+          this._isScheduledToHide = false;
+        }
+      }
+
+      /**
+       * Hovercard elements are created outside of <gr-app>, so if you want to fire
+       * events, then you probably want to do that through the target element.
+       */
+
+      dispatchEventThroughTarget(eventName: string): void;
+
+      dispatchEventThroughTarget(
+        eventName: 'show-alert',
+        detail: ShowAlertEventDetail
+      ): void;
+
+      dispatchEventThroughTarget(
+        eventName: 'reload',
+        detail: ReloadEventDetail
+      ): void;
+
+      dispatchEventThroughTarget(eventName: string, detail?: unknown) {
+        if (!detail) detail = {};
+        if (this._target)
+          this._target.dispatchEvent(
+            new CustomEvent(eventName, {
+              detail,
+              bubbles: true,
+              composed: true,
+            })
+          );
+      }
+
+      /**
+       * Returns the target element that the hovercard is anchored to (the `id` of
+       * the `for` property).
+       */
+      get target(): Element {
+        const parentNode = this.parentNode;
+        // If the parentNode is a document fragment, then we need to use the host.
+        const ownerRoot = this.getRootNode() as ShadowRoot;
+        let target;
+        if (this.for) {
+          target = ownerRoot.querySelector('#' + this.for);
+        } else {
+          target =
+            !parentNode || parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
+              ? ownerRoot.host
+              : parentNode;
+        }
+        return target as Element;
+      }
+
+      /**
+       * unlock scroll, this will resume the scroll outside of the hovercard.
+       */
+      unlock() {
+        removeScrollLock(this);
+      }
+
+      /**
+       * 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).
+       *
+       */
+      hide(e?: MouseEvent) {
+        this.cancelHideDebouncer();
+        this.cancelShowDebouncer();
+        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) {
+          if (
+            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.$['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.
+       */
+      debounceShow() {
+        this.debounceShowBy(SHOW_DELAY_MS);
+      }
+
+      /**
+       * Shows/opens the hovercard with the given delay.
+       */
+      debounceShowBy(delayMs: number) {
+        this.cancelHideDebouncer();
+        if (this._isShowing || this._isScheduledToShow) return;
+        this._isScheduledToShow = true;
+        this._showDebouncer = Debouncer.debounce(
+          this._showDebouncer,
+          timeOut.after(delayMs),
+          () => {
+            // This happens when the mouse leaves the target before the delay is over.
+            if (!this._isScheduledToShow) return;
+            this.show();
+          }
+        );
+      }
+
+      cancelShowDebouncer() {
+        if (this._showDebouncer) {
+          this._showDebouncer.cancel();
+          this._isScheduledToShow = false;
+        }
+      }
+
+      /**
+       * Lock background scroll but enable scroll inside of current hovercard.
+       */
+      lock() {
+        pushScrollLock(this);
+      }
+
+      /**
+       * Shows/opens the hovercard. This occurs when the user triggers the
+       * `mousenter` event on the hovercard's `target` element.
+       */
+      show() {
+        this.cancelHideDebouncer();
+        this.cancelShowDebouncer();
+        if (this._isShowing || !this.container) {
+          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: string) {
+        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;
+
+        switch (position) {
+          case 'top':
+            hovercardLeft =
+              targetLeft + (targetRect.width - thisRect.width) / 2;
+            hovercardTop = targetTop - thisRect.height - this.offset;
+            break;
+          case 'bottom':
+            hovercardLeft =
+              targetLeft + (targetRect.width - thisRect.width) / 2;
+            hovercardTop = targetTop + targetRect.height + this.offset;
+            break;
+          case 'left':
+            hovercardLeft = targetLeft - thisRect.width - this.offset;
+            hovercardTop =
+              targetTop + (targetRect.height - thisRect.height) / 2;
+            break;
+          case 'right':
+            hovercardLeft = targetLeft + targetRect.width + this.offset;
+            hovercardTop =
+              targetTop + (targetRect.height - thisRect.height) / 2;
+            break;
+          case 'bottom-right':
+            hovercardLeft = targetLeft + targetRect.width + this.offset;
+            hovercardTop = targetTop;
+            break;
+          case 'bottom-left':
+            hovercardLeft = targetLeft - thisRect.width - this.offset;
+            hovercardTop = targetTop;
+            break;
+          case 'top-left':
+            hovercardLeft = targetLeft - thisRect.width - this.offset;
+            hovercardTop = targetTop + targetRect.height - thisRect.height;
+            break;
+          case 'top-right':
+            hovercardLeft = targetLeft + targetRect.width + this.offset;
+            hovercardTop = targetTop + targetRect.height - thisRect.height;
+            break;
+        }
+
+        this.style.left = `${hovercardLeft}px`;
+        this.style.top = `${hovercardTop}px`;
+      }
+
+      /**
+       * Responds to a change in the `for` value and gets the updated `target`
+       * element for the hovercard.
+       */
+      @observe('for')
+      _forChanged() {
+        this._target = this.target;
+      }
+    }
+
+    return Mixin;
+  }
+);
+
+export interface GrHovercardBehaviorInterface {
+  attached(): void;
+  ready(): void;
+  removeListeners(): void;
+  debounceHide(): void;
+  cancelHideDebouncer(): void;
+  dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
+  hide(e?: MouseEvent): void;
+  debounceShow(): void;
+  debounceShowBy(delayMs: number): void;
+  cancelShowDebouncer(): void;
+  show(): void;
+  updatePosition(): void;
+  updatePositionTo(position: string): void;
+}
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
deleted file mode 100644
index 5fb1add..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** 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-shared-style.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
new file mode 100644
index 0000000..aa92654
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
@@ -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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+/** 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;
+        max-width: 600px;
+        outline: none;
+      }
+      :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.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
deleted file mode 100644
index e77a4c5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ /dev/null
@@ -1,36 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @extends Polymer.Element */
-class GrHovercard extends GestureEventListeners(
-    hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
-) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-hovercard'; }
-}
-
-customElements.define(GrHovercard.is, GrHovercard);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
new file mode 100644
index 0000000..c56bc8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
@@ -0,0 +1,40 @@
+/**
+ * @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.
+ */
+
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-hovercard_html';
+import {hovercardBehaviorMixin} from './gr-hovercard-behavior';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import './gr-hovercard-shared-style';
+import {customElement} from '@polymer/decorators';
+
+@customElement('gr-hovercard')
+export class GrHovercard extends GestureEventListeners(
+  hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-hovercard': 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
deleted file mode 100644
index 67a3545..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
new file mode 100644
index 0000000..830cbd878
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 21f692d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
+++ /dev/null
@@ -1,159 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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>
-<!-- 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">
-  <template>
-    <gr-hovercard for="foo" id="bar"></gr-hovercard>
-  </template>
-</test-fixture>
-
-<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', () => {
-    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-hovercard/gr-hovercard_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
new file mode 100644
index 0000000..6b2e620
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
@@ -0,0 +1,166 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-hovercard.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-hovercard for="foo" id="bar"></gr-hovercard>
+`);
+
+suite('gr-hovercard tests', () => {
+  let element;
+
+  let button;
+  let testResolve;
+  let testPromise;
+
+  setup(() => {
+    testResolve = undefined;
+    testPromise = new Promise(r => testResolve = r);
+    button = document.createElement('button');
+    button.innerHTML = 'Hello';
+    button.setAttribute('id', 'foo');
+    document.body.appendChild(button);
+
+    element = basicFixture.instantiate();
+  });
+
+  teardown(() => {
+    element.hide({});
+    button.remove();
+  });
+
+  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, 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('debounceShow does not show immediately', async () => {
+    element.debounceShowBy(100);
+    setTimeout(testResolve, 0);
+    await testPromise;
+    assert.isFalse(element._isShowing);
+  });
+
+  test('debounceShow shows after delay', async () => {
+    element.debounceShowBy(1);
+    setTimeout(testResolve, 10);
+    await testPromise;
+    assert.isTrue(element._isShowing);
+  });
+
+  test('card is scheduled to show on enter and hides on leave', async () => {
+    const button = document.querySelector('button');
+    let enterResolve = undefined;
+    const enterPromise = new Promise(r => enterResolve = r);
+    button.addEventListener('mouseenter', enterResolve);
+    let leaveResolve = undefined;
+    const leavePromise = new Promise(r => leaveResolve = r);
+    button.addEventListener('mouseleave', leaveResolve);
+
+    assert.isFalse(element._isShowing);
+    button.dispatchEvent(new CustomEvent('mouseenter'));
+
+    await enterPromise;
+    assert.isTrue(element._isScheduledToShow);
+    element._showDebouncer.flush();
+    assert.isTrue(element._isShowing);
+    assert.isFalse(element._isScheduledToShow);
+
+    button.dispatchEvent(new CustomEvent('mouseleave'));
+
+    await leavePromise;
+    assert.isTrue(element._isScheduledToHide);
+    assert.isTrue(element._isShowing);
+    element._hideDebouncer.flush();
+    assert.isFalse(element._isScheduledToShow);
+    assert.isFalse(element._isShowing);
+
+    button.removeEventListener('mouseenter', enterResolve);
+    button.removeEventListener('mouseleave', leaveResolve);
+  });
+
+  test('card should disappear on click', async () => {
+    const button = document.querySelector('button');
+    let enterResolve = undefined;
+    const enterPromise = new Promise(r => enterResolve = r);
+    button.addEventListener('mouseenter', enterResolve);
+    let clickResolve = undefined;
+    const clickPromise = new Promise(r => clickResolve = r);
+    button.addEventListener('click', clickResolve);
+
+    assert.isFalse(element._isShowing);
+
+    button.dispatchEvent(new CustomEvent('mouseenter'));
+
+    await enterPromise;
+    assert.isTrue(element._isScheduledToShow);
+    MockInteractions.tap(button);
+
+    await clickPromise;
+    assert.isFalse(element._isScheduledToShow);
+    assert.isFalse(element._isShowing);
+
+    button.removeEventListener('mouseenter', enterResolve);
+    button.removeEventListener('click', clickResolve);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
deleted file mode 100644
index 84d0d2a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
+++ /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.
- */
-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-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
new file mode 100644
index 0000000..7112e3b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -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.
+ */
+import '@polymer/iron-icon/iron-icon';
+import '@polymer/iron-iconset-svg/iron-iconset-svg';
+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 material.io https://material.io/resources/icons/?icon=help_outline -->
+      <g id="help-outline"><path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></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 iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></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 SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="check-circle"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></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="M1 23 l13 0 c.67 0 1.27 -.33 1.63 -.84 l7.37 -10.16 l-7.37 -10.16 c-.36 -.51 -.96 -.84 -1.63 -.84 L1 1 L7 12 z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#pets-->
+      <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></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>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
+      <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#bug_report-->
+      <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></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
deleted file mode 100644
index 6514aa2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
+++ /dev/null
@@ -1,71 +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.
- */
-
-import {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation.js';
-
-/**
- * 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 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);
-  }
-};
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
new file mode 100644
index 0000000..0cb628c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
@@ -0,0 +1,100 @@
+/**
+ * @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 {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation';
+import {GrStyleObject} from '../../plugins/gr-styles-api/gr-styles-api';
+import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
+
+/**
+ * Used to create a context for GrAnnotationActionsInterface.
+ *
+ * @param contentEl The DIV.contentText element of the line
+ * content to apply the annotation to using annotateRange.
+ * @param lineNumberEl The TD element of the line number to
+ * apply the annotation to using annotateLineNumber.
+ * @param line The line object.
+ * @param path The file path (eg: /COMMIT_MSG').
+ * @param changeNum The Gerrit change number.
+ * @param patchNum The Gerrit patch number.
+ */
+export class GrAnnotationActionsContext {
+  private _contentEl: HTMLElement;
+
+  private _lineNumberEl: HTMLElement;
+
+  line: GrDiffLine;
+
+  path: string;
+
+  changeNum: number;
+
+  constructor(
+    contentEl: HTMLElement,
+    lineNumberEl: HTMLElement,
+    line: GrDiffLine,
+    path: string,
+    changeNum: string | number
+  ) {
+    this._contentEl = contentEl;
+    this._lineNumberEl = lineNumberEl;
+
+    this.line = line;
+    this.path = path;
+    this.changeNum = Number(changeNum);
+    if (isNaN(this.changeNum)) {
+      console.error(
+        `GrAnnotationActionsContext: Invalid changeNum: ${changeNum}`
+      );
+    }
+  }
+
+  /**
+   * Method to add annotations to a content line.
+   *
+   * @param offset The char offset where the update starts.
+   * @param length The number of chars that the update covers.
+   * @param styleObject The style object for the range.
+   * @param side The side of the update. ('left' or 'right')
+   */
+  annotateRange(
+    offset: number,
+    length: number,
+    styleObject: GrStyleObject,
+    side: string
+  ) {
+    if (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 styleObject The style object for the range.
+   * @param side The side of the update. ('left' or 'right')
+   */
+  annotateLineNumber(styleObject: GrStyleObject, side: string) {
+    if (this._lineNumberEl?.classList.contains(side)) {
+      styleObject.apply(this._lineNumberEl);
+    }
+  }
+}
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
deleted file mode 100644
index a14612b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
+++ /dev/null
@@ -1,104 +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">
-<meta charset="utf-8">
-<title>gr-annotation-actions-context</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>
-    <div></div>
-  </template>
-</test-fixture>
-
-<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();
-
-suite('gr-annotation-actions-context tests', () => {
-  let instance;
-  let sandbox;
-  let el;
-  let lineNumberEl;
-  let plugin;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-
-    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-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.js
new file mode 100644
index 0000000..b46a3b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.js
@@ -0,0 +1,87 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.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();
+
+suite('gr-annotation-actions-context tests', () => {
+  let instance;
+
+  let el;
+  let lineNumberEl;
+  let plugin;
+
+  setup(() => {
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+
+    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(() => {
+    el.remove();
+  });
+
+  test('test annotateRange', () => {
+    const annotateElementSpy = sinon.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));
+  });
+});
+
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
deleted file mode 100644
index 3b24404..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
+++ /dev/null
@@ -1,226 +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.
- */
-import {GrAnnotationActionsContext} from './gr-annotation-actions-context.js';
-
-/** @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 = [];
-
-  this._coverageProvider = null;
-
-  // 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;
-};
-
-/**
- * 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;
-    }
-  }
-};
-
-/**
- * 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.
- *
- * @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;
-
-  this._listeners = [];
-}
-
-/**
- * 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.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
new file mode 100644
index 0000000..e069f8b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -0,0 +1,279 @@
+/**
+ * @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 {GrAnnotationActionsContext} from './gr-annotation-actions-context';
+import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
+import {
+  CoverageRange,
+  DiffLayer,
+  DiffLayerListener,
+} from '../../../types/types';
+import {Side} from '../../../constants/constants';
+import {PluginApi} from '../../plugins/gr-plugin-types';
+import {ChangeInfo, NumericChangeId} from '../../../types/common';
+
+type AddLayerFunc = (ctx: GrAnnotationActionsContext) => void;
+
+type NotifyFunc = (
+  path: string,
+  start: number,
+  end: number,
+  side: Side
+) => void;
+
+export type CoverageProvider = (
+  changeNum: NumericChangeId,
+  path: string,
+  basePatchNum?: number,
+  patchNum?: number,
+  change?: ChangeInfo
+) => Promise<Array<CoverageRange>>;
+
+export class GrAnnotationActionsInterface {
+  // Collect all annotation layers instantiated by getLayer. Will be used when
+  // notifying their listeners in the notify function.
+  private annotationLayers: AnnotationLayer[] = [];
+
+  private coverageProvider: CoverageProvider | null = null;
+
+  // Default impl is a no-op.
+  private addLayerFunc: AddLayerFunc = () => {};
+
+  constructor(private readonly plugin: PluginApi) {
+    // Return this instance when there is an annotatediff event.
+    plugin.on('annotatediff', 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 addLayerFunc The function
+   * that will be called when the AnnotationLayer is ready to annotate.
+   */
+  addLayer(addLayerFunc: 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 notifyFunc See doc of the notify function below to see what it does.
+   */
+  addNotifier(notifyFunc: (n: NotifyFunc) => void) {
+    notifyFunc(
+      (path: string, startRange: number, endRange: number, side: Side) =>
+        this.notify(path, startRange, endRange, side)
+    );
+    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.
+   */
+  setCoverageProvider(
+    coverageProvider: CoverageProvider
+  ): GrAnnotationActionsInterface {
+    if (this.coverageProvider) {
+      console.warn('Overwriting an existing coverage provider.');
+    }
+    this.coverageProvider = coverageProvider;
+    return this;
+  }
+
+  /**
+   * Used by Gerrit to look up the coverage provider. Not intended to be called
+   * by plugins.
+   */
+  getCoverageProvider() {
+    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 checkboxLabel Will be used as the label for the checkbox.
+   * Optional. "Enable" is used if this is not specified.
+   * @param onAttached The function that will be called
+   * when the checkbox is attached to the page.
+   */
+  enableToggleCheckbox(
+    checkboxLabel: string,
+    onAttached: (checkboxEl: Element | null) => void
+  ) {
+    this.plugin.hook('annotation-toggler').onAttached(element => {
+      if (!element.content) {
+        console.error('plugin endpoint without content.');
+        return;
+      }
+      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 (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 path The file path whose listeners should be notified.
+   * @param start The line where the update starts.
+   * @param end The line where the update ends.
+   * @param side The side of the update ('left' or 'right').
+   */
+  notify(path: string, start: number, end: number, side: 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(start, end, side);
+      }
+    }
+  }
+
+  /**
+   * Should be called to register annotation layers by the framework. Not
+   * intended to be called by plugins.
+   *
+   * Don't forget to dispose layer.
+   *
+   * @param path The file path (eg: /COMMIT_MSG').
+   * @param changeNum The Gerrit change number.
+   */
+  getLayer(path: string, changeNum: number) {
+    const annotationLayer = new AnnotationLayer(
+      path,
+      changeNum,
+      this.addLayerFunc
+    );
+    this.annotationLayers.push(annotationLayer);
+    return annotationLayer;
+  }
+
+  disposeLayer(path: string) {
+    this.annotationLayers = this.annotationLayers.filter(
+      annotationLayer => annotationLayer.path !== path
+    );
+  }
+}
+
+export class AnnotationLayer implements DiffLayer {
+  private listeners: DiffLayerListener[] = [];
+
+  /**
+   * Used to create an instance of the Annotation Layer interface.
+   *
+   * @param path The file path (eg: /COMMIT_MSG').
+   * @param changeNum The Gerrit change number.
+   * @param addLayerFunc The function
+   * that will be called when the AnnotationLayer is ready to annotate.
+   */
+  constructor(
+    readonly path: string,
+    private readonly changeNum: number,
+    private readonly addLayerFunc: AddLayerFunc
+  ) {
+    this.listeners = [];
+  }
+
+  /**
+   * Register a listener for layer updates.
+   * Don't forget to removeListener when you stop using layer.
+   *
+   * @param 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(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param contentEl The DIV.contentText element of the line
+   * content to apply the annotation to using annotateRange.
+   * @param lineNumberEl The TD element of the line number to
+   * apply the annotation to using annotateLineNumber.
+   * @param line The line object.
+   */
+  annotate(
+    contentEl: HTMLElement,
+    lineNumberEl: HTMLElement,
+    line: GrDiffLine
+  ) {
+    const annotationActionsContext = new GrAnnotationActionsContext(
+      contentEl,
+      lineNumberEl,
+      line,
+      this.path,
+      this.changeNum
+    );
+    this.addLayerFunc(annotationActionsContext);
+  }
+
+  /**
+   * Notify Layer listeners of changes to annotations.
+   *
+   * @param start The line where the update starts.
+   * @param end The line where the update ends.
+   * @param side The side of the update. ('left' or 'right')
+   */
+  notifyListeners(start: number, end: number, side: Side) {
+    for (const listener of this.listeners) {
+      listener(start, end, 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
deleted file mode 100644
index e72fc63..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
+++ /dev/null
@@ -1,192 +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">
-<meta charset="utf-8">
-<title>gr-annotation-actions-js-api-js-api</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>
-    <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>
-  </template>
-</test-fixture>
-
-<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';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-annotation-actions-js-api tests', () => {
-  let annotationActions;
-  let sandbox;
-  let plugin;
-
-  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-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
new file mode 100644
index 0000000..8b3f501
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../change/gr-change-actions/gr-change-actions.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+  <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>
+`);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-annotation-actions-js-api tests', () => {
+  let annotationActions;
+
+  let plugin;
+
+  setup(() => {
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    annotationActions = plugin.annotationApi();
+  });
+
+  teardown(() => {
+    annotationActions = null;
+  });
+
+  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;
+    let testLayerFuncCalled = false;
+
+    const testLayerFunc = context => {
+      testLayerFuncCalled = true;
+      assert.equal(context.line, line);
+      assert.equal(context.changeNum, changeNum);
+    };
+    annotationActions.addLayer(testLayerFunc);
+
+    const annotationLayer = annotationActions.getLayer(
+        '/dummy/path', changeNum);
+
+    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);
+    const annotationLayer2 = annotationActions.getLayer(path2, 1);
+    const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
+    const layer2Spy = sinon.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.resetHistory();
+    layer2Spy.resetHistory();
+
+    // 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: basicFixture.instantiate()};
+    const hookStub = {onAttached: sinon.stub()};
+    sinon.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 = sinon.stub(
+        console, 'error').callsFake((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);
+    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);
+  });
+});
+
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
deleted file mode 100644
index 5b58a5c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-
-export const PRELOADED_PROTOCOL = 'preloaded:';
-export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
-
-let _restAPI;
-export function getRestAPI() {
-  if (!_restAPI) {
-    _restAPI = document.createElement('gr-rest-api-interface');
-  }
-  return _restAPI;
-}
-
-export function getBaseUrl() {
-  return BaseUrlBehavior.getBaseUrl();
-}
-
-/**
- * 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;
-    }
-  }
-  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;
-  }
-
-  // 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
-
-export function testOnly_resetInternalState() {
-  _restAPI = undefined;
-}
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
new file mode 100644
index 0000000..8f743e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -0,0 +1,113 @@
+/**
+ * @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 {getBaseUrl} from '../../../utils/url-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {HttpMethod} from '../../../constants/constants';
+import {RequestPayload} from '../../../types/common';
+
+export const PRELOADED_PROTOCOL = 'preloaded:';
+export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
+
+let _restAPI: RestApiService | undefined;
+export function getRestAPI() {
+  if (!_restAPI) {
+    _restAPI = (document.createElement(
+      'gr-rest-api-interface'
+    ) as unknown) as RestApiService;
+  }
+  return _restAPI;
+}
+
+/**
+ * Retrieves the name of the plugin base on the url.
+ */
+export function getPluginNameFromUrl(url: URL | string) {
+  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 = 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 (
+    ['/static/gerrit-theme.html', '/static/gerrit-theme.js'].includes(pathname)
+  ) {
+    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;
+  }
+
+  // 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: HttpMethod,
+  url: string,
+  opt_callback?: (response: unknown) => void,
+  opt_payload?: RequestPayload
+) {
+  return getRestAPI()
+    .send(method, url, opt_payload)
+    .then(response => {
+      if (response.status < 200 || response.status >= 300) {
+        return response.text().then((text: string | undefined) => {
+          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
+
+export function testOnly_resetInternalState() {
+  _restAPI = undefined;
+}
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
deleted file mode 100644
index d01566a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
+++ /dev/null
@@ -1,85 +0,0 @@
-<!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-api-interface</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 './gr-js-api-interface.js';
-import {getPluginNameFromUrl} from './gr-api-utils.js';
-
-const PRELOADED_PROTOCOL = 'preloaded:';
-
-suite('gr-api-utils tests', () => {
-  suite('test getPluginNameFromUrl', () => {
-    test('with empty string', () => {
-      assert.equal(getPluginNameFromUrl(''), 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 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 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>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
new file mode 100644
index 0000000..85c62cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {getPluginNameFromUrl} from './gr-api-utils.js';
+
+const PRELOADED_PROTOCOL = 'preloaded:';
+
+suite('gr-api-utils tests', () => {
+  suite('test getPluginNameFromUrl', () => {
+    test('with empty string', () => {
+      assert.equal(getPluginNameFromUrl(''), 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 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 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;
+    });
+  });
+});
+
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
deleted file mode 100644
index 8ab97f8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ /dev/null
@@ -1,135 +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.
- */
-
-/**
- * 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;
-}
-
-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; }
-
-  this._el.push('primaryActionKeys', 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.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.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.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.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.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.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);
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
new file mode 100644
index 0000000..a493a2e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -0,0 +1,211 @@
+/**
+ * @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 {
+  ActionType,
+  ActionPriority,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {JsApiService} from './gr-js-api-types';
+import {TargetElement} from '../../plugins/gr-plugin-types';
+import {ActionInfo, RequireProperties} from '../../../types/common';
+
+interface Plugin {
+  getPluginName(): string;
+}
+
+export enum 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 = 'rebase',
+  REBASE_EDIT = 'rebaseEdit',
+  READY = 'ready',
+  RESTORE = 'restore',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  REVIEWED = 'reviewed',
+  STOP_EDIT = 'stopEdit',
+  SUBMIT = 'submit',
+  UNIGNORE = 'unignore',
+  UNREVIEWED = 'unreviewed',
+  WIP = 'wip',
+}
+
+export enum RevisionActions {
+  CHERRYPICK = 'cherrypick',
+  REBASE = 'rebase',
+  SUBMIT = 'submit',
+  DOWNLOAD = 'download',
+}
+
+export type PrimaryActionKey = ChangeActions | RevisionActions;
+
+export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
+  __key: string;
+  __url?: string;
+  __primary?: boolean;
+  __type: ActionType;
+  icon?: string;
+}
+
+// This interface is required to avoid circular dependencies between files;
+export interface GrChangeActionsElement extends Element {
+  RevisionActions?: Record<string, string>;
+  ChangeActions: Record<string, string>;
+  ActionType: Record<string, string>;
+  primaryActionKeys: string[];
+  push(propName: 'primaryActionKeys', value: string): void;
+  hideQuickApproveAction(): void;
+  setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
+  setActionPriority(
+    type: ActionType,
+    key: string,
+    overflow: ActionPriority
+  ): void;
+  setActionHidden(type: ActionType, key: string, hidden: boolean): void;
+  addActionButton(type: ActionType, label: string): string;
+  removeActionButton(key: string): void;
+  setActionButtonProp<T extends keyof UIActionInfo>(
+    key: string,
+    prop: T,
+    value: UIActionInfo[T]
+  ): void;
+  getActionDetails(actionName: string): ActionInfo | undefined;
+}
+
+export class GrChangeActionsInterface {
+  private _el?: GrChangeActionsElement;
+
+  RevisionActions = RevisionActions;
+
+  ChangeActions = ChangeActions;
+
+  ActionType = ActionType;
+
+  constructor(public plugin: Plugin, el?: GrChangeActionsElement) {
+    this.setEl(el);
+  }
+
+  /**
+   * Set gr-change-actions element to a GrChangeActionsInterface instance.
+   */
+  private setEl(el?: GrChangeActionsElement) {
+    if (!el) {
+      console.warn('changeActions() is not ready');
+      return;
+    }
+    this._el = el;
+  }
+
+  /**
+   * Ensure GrChangeActionsInterface instance has access to gr-change-actions
+   * element and retrieve if the interface was created before element.
+   */
+  private ensureEl(): GrChangeActionsElement {
+    if (!this._el) {
+      const sharedApiElement = (document.createElement(
+        'gr-js-api-interface'
+      ) as unknown) as JsApiService;
+      this.setEl(
+        (sharedApiElement.getElement(
+          TargetElement.CHANGE_ACTIONS
+        ) as unknown) as GrChangeActionsElement
+      );
+    }
+    return this._el!;
+  }
+
+  addPrimaryActionKey(key: PrimaryActionKey) {
+    const el = this.ensureEl();
+    if (el.primaryActionKeys.includes(key)) {
+      return;
+    }
+
+    el.push('primaryActionKeys', key);
+  }
+
+  removePrimaryActionKey(key: string) {
+    const el = this.ensureEl();
+    el.primaryActionKeys = el.primaryActionKeys.filter(k => k !== key);
+  }
+
+  hideQuickApproveAction() {
+    this.ensureEl().hideQuickApproveAction();
+  }
+
+  setActionOverflow(type: ActionType, key: string, overflow: boolean) {
+    // TODO(TS): remove return, unclear why it was written
+    return this.ensureEl().setActionOverflow(type, key, overflow);
+  }
+
+  setActionPriority(type: ActionType, key: string, priority: ActionPriority) {
+    // TODO(TS): remove return, unclear why it was written
+    return this.ensureEl().setActionPriority(type, key, priority);
+  }
+
+  setActionHidden(type: ActionType, key: string, hidden: boolean) {
+    // TODO(TS): remove return, unclear why it was written
+    return this.ensureEl().setActionHidden(type, key, hidden);
+  }
+
+  add(type: ActionType, label: string): string {
+    return this.ensureEl().addActionButton(type, label);
+  }
+
+  remove(key: string) {
+    // TODO(TS): remove return, unclear why it was written
+    return this.ensureEl().removeActionButton(key);
+  }
+
+  addTapListener(key: string, handler: EventListenerOrEventListenerObject) {
+    this.ensureEl().addEventListener(key + '-tap', handler);
+  }
+
+  removeTapListener(key: string, handler: EventListenerOrEventListenerObject) {
+    this.ensureEl().removeEventListener(key + '-tap', handler);
+  }
+
+  setLabel(key: string, text: string) {
+    this.ensureEl().setActionButtonProp(key, 'label', text);
+  }
+
+  setTitle(key: string, text: string) {
+    this.ensureEl().setActionButtonProp(key, 'title', text);
+  }
+
+  setEnabled(key: string, enabled: boolean) {
+    this.ensureEl().setActionButtonProp(key, 'enabled', enabled);
+  }
+
+  setIcon(key: string, icon: string) {
+    this.ensureEl().setActionButtonProp(key, 'icon', icon);
+  }
+
+  getActionDetails(action: string) {
+    const el = this.ensureEl();
+    return (
+      el.getActionDetails(action) ||
+      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
deleted file mode 100644
index 1d5e423..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ /dev/null
@@ -1,232 +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">
-<meta charset="utf-8">
-<title>gr-change-actions-js-api</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>
-<!--
-This must refer to the element this interface is wrapping around. Otherwise
-breaking changes to gr-change-actions won’t be noticed.
--->
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-actions></gr-change-actions>
-  </template>
-</test-fixture>
-
-<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();
-
-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(() => {
-      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');
-      });
-    });
-  });
-
-  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([]);
-    });
-
-    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();
-        });
-      });
-    });
-
-    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(() => {
-          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('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.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]');
-          assert.isNotOk(button);
-          done();
-        });
-      });
-    });
-
-    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(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[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 =
-          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 =
-            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-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
new file mode 100644
index 0000000..203784d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
@@ -0,0 +1,198 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../change/gr-change-actions/gr-change-actions.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {getPluginLoader} from './gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-change-actions');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-actions-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(() => {
+      resetPlugins();
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      // Mimic all plugins loaded.
+      getPluginLoader().loadPlugins([]);
+      changeActions = plugin.changeActions();
+      element = basicFixture.instantiate();
+    });
+
+    teardown(() => {
+      changeActions = null;
+      resetPlugins();
+    });
+
+    test('does not throw', ()=> {
+      assert.doesNotThrow(() => {
+        changeActions.add('change', 'foo');
+      });
+    });
+  });
+
+  suite('normal init', () => {
+    setup(() => {
+      resetPlugins();
+      element = basicFixture.instantiate();
+      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.
+      getPluginLoader().loadPlugins([]);
+    });
+
+    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', () => {
+      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 + '"]'));
+    });
+
+    test('action button properties', () => {
+      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();
+      assert.equal(button.getAttribute('data-label'), 'Yo');
+      assert.equal(button.getAttribute('title'), 'Yo hint');
+      assert.isTrue(button.disabled);
+      assert.equal(button.querySelector('iron-icon').icon,
+          'gr-icons:pupper');
+    });
+
+    test('hide action buttons', () => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush();
+      let button = element.shadowRoot
+          .querySelector('[data-action-key="' + key + '"]');
+      assert.isOk(button);
+      assert.isFalse(button.hasAttribute('hidden'));
+      changeActions.setActionHidden(
+          changeActions.ActionType.REVISION, key, true);
+      flush();
+      button = element.shadowRoot
+          .querySelector('[data-action-key="' + key + '"]');
+      assert.isNotOk(button);
+    });
+
+    test('move action button to overflow', async () => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      await flush();
+      assert.isTrue(element.$.moreActions.hidden);
+      assert.isOk(element.shadowRoot
+          .querySelector('[data-action-key="' + key + '"]'));
+      changeActions.setActionOverflow(
+          changeActions.ActionType.REVISION, key, true);
+      flush();
+      assert.isNotOk(element.shadowRoot
+          .querySelector('[data-action-key="' + key + '"]'));
+      assert.isFalse(element.$.moreActions.hidden);
+      assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
+    });
+
+    test('change actions priority', () => {
+      const key1 =
+        changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const key2 =
+        changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
+      flush();
+      let buttons =
+        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 =
+        element.root.querySelectorAll('[data-action-key]');
+      assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+      assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+    });
+  });
+});
+
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
deleted file mode 100644
index da0157f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ /dev/null
@@ -1,74 +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.
- */
-
-/**
- * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
- */
-export class GrChangeReplyInterface {
-  constructor(plugin) {
-    this.plugin = plugin;
-    this._sharedApiEl = Plugin._sharedAPIElement;
-  }
-
-  get _el() {
-    return this._sharedApiEl.getElement(
-        this._sharedApiEl.Element.REPLY_DIALOG);
-  }
-
-  getLabelValue(label) {
-    return this._el.getLabelValue(label);
-  }
-
-  setLabelValue(label, value) {
-    this._el.setLabelValue(label, value);
-  }
-
-  send(opt_includeComments) {
-    this._el.send(opt_includeComments);
-  }
-
-  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);
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
new file mode 100644
index 0000000..7069304
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
@@ -0,0 +1,105 @@
+/**
+ * @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 {GrReplyDialog} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {PluginApi, TargetElement} from '../../plugins/gr-plugin-types';
+import {JsApiService} from './gr-js-api-types';
+
+// TODO(TS): maybe move interfaces\types to other files when convertion complete
+interface LabelsChangedDetail {
+  name: string;
+  value: string;
+}
+interface ValueChangedDetail {
+  value: string;
+}
+
+type ReplyChangedCallback = (text: string) => void;
+type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+
+/**
+ * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
+ */
+export class GrChangeReplyInterface {
+  constructor(
+    readonly plugin: PluginApi,
+    readonly sharedApiElement: JsApiService
+  ) {}
+
+  get _el(): GrReplyDialog {
+    return (this.sharedApiElement.getElement(
+      TargetElement.REPLY_DIALOG
+    ) as unknown) as GrReplyDialog;
+  }
+
+  getLabelValue(label: string) {
+    return this._el.getLabelValue(label);
+  }
+
+  setLabelValue(label: string, value: string) {
+    this._el.setLabelValue(label, value);
+  }
+
+  send(includeComments?: boolean) {
+    this._el.send(includeComments);
+  }
+
+  addReplyTextChangedCallback(handler: ReplyChangedCallback) {
+    const hookApi = this.plugin.hook('reply-text');
+    const registeredHandler = (e: Event) => {
+      const ce = e as CustomEvent<ValueChangedDetail>;
+      handler(ce.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: LabelsChangedCallback) {
+    const hookApi = this.plugin.hook('reply-label-scores');
+    const registeredHandler = (e: Event) => {
+      const ce = e as CustomEvent<LabelsChangedDetail>;
+      handler(ce.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: string) {
+    return this._el.setPluginMessage(message);
+  }
+}
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
deleted file mode 100644
index 0360f85..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ /dev/null
@@ -1,125 +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">
-<meta charset="utf-8">
-<title>gr-change-reply-js-api</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>
-<!--
-This must refer to the element this interface is wrapping around. Otherwise
-breaking changes to gr-reply-dialog won’t be noticed.
--->
-
-<test-fixture id="basic">
-  <template>
-    <gr-reply-dialog></gr-reply-dialog>
-  </template>
-</test-fixture>
-
-<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(() => {
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeReply = plugin.changeReply();
-      element = fixture('basic');
-    });
-
-    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'));
-    });
-  });
-
-  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-change-reply-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
new file mode 100644
index 0000000..8f41b39
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../change/gr-reply-dialog/gr-reply-dialog.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-reply-js-api tests', () => {
+  let element;
+
+  let changeReply;
+  let plugin;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve(null); },
+    });
+  });
+
+  suite('early init', () => {
+    setup(() => {
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeReply = plugin.changeReply();
+      element = basicFixture.instantiate();
+    });
+
+    teardown(() => {
+      changeReply = null;
+    });
+
+    test('works', () => {
+      sinon.stub(element, 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+      sinon.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(
+          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+      sinon.stub(element, 'send');
+      changeReply.send(false);
+      assert.isTrue(element.send.calledWithExactly(false));
+
+      sinon.stub(element, 'setPluginMessage');
+      changeReply.showMessage('foobar');
+      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
+    });
+  });
+
+  suite('normal init', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeReply = plugin.changeReply();
+    });
+
+    teardown(() => {
+      changeReply = null;
+    });
+
+    test('works', () => {
+      sinon.stub(element, 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+      sinon.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(
+          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+      sinon.stub(element, 'send');
+      changeReply.send(false);
+      assert.isTrue(element.send.calledWithExactly(false));
+
+      sinon.stub(element, 'setPluginMessage');
+      changeReply.showMessage('foobar');
+      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
+    });
+  });
+});
+
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
deleted file mode 100644
index ef57ae9..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
+++ /dev/null
@@ -1,186 +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 defines the Gerrit instance. All methods directly attached to Gerrit
- * should be defined or linked here.
- */
-
-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';
-
-/**
- * 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();
-  }
-}
-export const _testOnly_flushPreinstalls = flushPreinstalls;
-
-export function initGerritPluginApi() {
-  window.Gerrit = window.Gerrit || {};
-  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();
-}
-
-export function _testOnly_initGerritPluginApi() {
-  initGerritPluginApi();
-  return window.Gerrit;
-}
-
-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.
-   */
-  globalGerritObj.css = function(rulesStr) {
-    console.warn('Gerrit.css(rulesStr) is deprecated!',
-        'Use plugin.styles().css(rulesStr)');
-    if (!globalGerritObj._customStyleSheet) {
-      const styleEl = document.createElement('style');
-      document.head.appendChild(styleEl);
-      globalGerritObj._customStyleSheet = styleEl.sheet;
-    }
-
-    const name = '__pg_js_api_class_' +
-        globalGerritObj._customStyleSheet.cssRules.length;
-    globalGerritObj._customStyleSheet
-        .insertRule('.' + name + '{' + rulesStr + '}', 0);
-    return name;
-  };
-
-  globalGerritObj.install = function(callback, opt_version, opt_src) {
-    pluginLoader.install(callback, opt_version, opt_src);
-  };
-
-  globalGerritObj.getLoggedIn = function() {
-    console.warn('Gerrit.getLoggedIn() is deprecated! ' +
-        'Use plugin.restApi().getLoggedIn()');
-    return document.createElement('gr-rest-api-interface').getLoggedIn();
-  };
-
-  globalGerritObj.get = function(url, callback) {
-    console.warn('.get() is deprecated! Use plugin.restApi().get()');
-    send('GET', url, callback);
-  };
-
-  globalGerritObj.post = function(url, payload, callback) {
-    console.warn('.post() is deprecated! Use plugin.restApi().post()');
-    send('POST', url, callback, payload);
-  };
-
-  globalGerritObj.put = function(url, payload, callback) {
-    console.warn('.put() is deprecated! Use plugin.restApi().put()');
-    send('PUT', url, callback, payload);
-  };
-
-  globalGerritObj.delete = function(url, opt_callback) {
-    deprecatedDelete(url, opt_callback);
-  };
-
-  globalGerritObj.awaitPluginsLoaded = function() {
-    return pluginLoader.awaitPluginsLoaded();
-  };
-
-  // TODO(taoalpha): consider removing these proxy methods
-  // and using pluginLoader directly
-  globalGerritObj._loadPlugins = function(plugins, opt_option) {
-    pluginLoader.loadPlugins(plugins, opt_option);
-  };
-
-  globalGerritObj._arePluginsLoaded = function() {
-    return pluginLoader.arePluginsLoaded();
-  };
-
-  globalGerritObj._isPluginPreloaded = function(url) {
-    return pluginLoader.isPluginPreloaded(url);
-  };
-
-  globalGerritObj._isPluginEnabled = function(pathOrUrl) {
-    return pluginLoader.isPluginEnabled(pathOrUrl);
-  };
-
-  globalGerritObj._isPluginLoaded = function(pathOrUrl) {
-    return pluginLoader.isPluginLoaded(pathOrUrl);
-  };
-
-  // TODO(taoalpha): List all internal supported event names.
-  // Also convert this to inherited class once we move Gerrit to class.
-  globalGerritObj._eventEmitter = gerritEventEmitter;
-  ['addListener',
-    'dispatch',
-    'emit',
-    'off',
-    'on',
-    'once',
-    'removeAllListeners',
-    'removeListener',
-  ].forEach(method => {
-    /**
-     * Enabling EventEmitter interface on Gerrit.
-     *
-     * This will enable to signal across different parts of js code without relying on DOM,
-     * including core to core, plugin to plugin and also core to plugin.
-     *
-     * @example
-     *
-     * // Emit this event from pluginA
-     * Gerrit.install(pluginA => {
-     *   fetch("some-api").then(() => {
-     *     Gerrit.on("your-special-event", {plugin: pluginA});
-     *   });
-     * });
-     *
-     * // Listen on your-special-event from pluignB
-     * Gerrit.install(pluginB => {
-     *   Gerrit.on("your-special-event", ({plugin}) => {
-     *     // do something, plugin is pluginA
-     *   });
-     * });
-     */
-    globalGerritObj[method] = gerritEventEmitter[method]
-        .bind(gerritEventEmitter);
-  });
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
new file mode 100644
index 0000000..37ac354
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -0,0 +1,276 @@
+/**
+ * @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 defines the Gerrit instance. All methods directly attached to Gerrit
+ * should be defined or linked here.
+ */
+import {
+  getPluginLoader,
+  PluginOptionMap,
+  PluginLoader,
+} from './gr-plugin-loader';
+import {getRestAPI, send} from './gr-api-utils';
+import {appContext} from '../../../services/app-context';
+import {PluginApi} from '../../plugins/gr-plugin-types';
+import {HttpMethod} from '../../../constants/constants';
+import {RequestPayload} from '../../../types/common';
+import {
+  EventCallback,
+  EventEmitterService,
+} from '../../../services/gr-event-interface/gr-event-interface';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getRootElement} from '../../../scripts/rootElement';
+import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {rangesEqual} from '../../diff/gr-diff/gr-diff-utils';
+import {SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {CoverageType} from '../../../types/types';
+import {RevisionInfo} from '../revision-info/revision-info';
+
+export interface GerritGlobal extends EventEmitterService {
+  flushPreinstalls?(): void;
+  css(rule: string): string;
+  install(
+    callback: (plugin: PluginApi) => void,
+    opt_version?: string,
+    src?: string
+  ): void;
+  getLoggedIn(): Promise<boolean>;
+  get(url: string, callback?: (response: unknown) => void): void;
+  post(
+    url: string,
+    payload?: RequestPayload,
+    callback?: (response: unknown) => void
+  ): void;
+  put(
+    url: string,
+    payload?: RequestPayload,
+    callback?: (response: unknown) => void
+  ): void;
+  delete(url: string, callback?: (response: unknown) => void): void;
+  isPluginLoaded(pathOrUrl: string): boolean;
+  awaitPluginsLoaded(): Promise<unknown>;
+  _loadPlugins(plugins: string[], opts: PluginOptionMap): void;
+  _arePluginsLoaded(): boolean;
+  _isPluginPreloaded(pathOrUrl: string): boolean;
+  _isPluginEnabled(pathOrUrl: string): boolean;
+  _isPluginLoaded(pathOrUrl: string): boolean;
+  _eventEmitter: EventEmitterService;
+  _customStyleSheet: CSSStyleSheet;
+
+  // exposed methods
+  Nav: typeof GerritNav;
+  Auth: typeof appContext.authService;
+  getRootElement: typeof getRootElement;
+  _pluginLoader: PluginLoader;
+  _endpoints: GrPluginEndpoints;
+  slotToContent(slot: unknown): unknown;
+  rangesEqual: typeof rangesEqual;
+  SUGGESTIONS_PROVIDERS_USERS_TYPES: typeof SUGGESTIONS_PROVIDERS_USERS_TYPES;
+  CoverageType: typeof CoverageType;
+  RevisionInfo: typeof RevisionInfo;
+}
+
+/**
+ * Trigger the preinstalls for bundled plugins.
+ * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
+ */
+function flushPreinstalls() {
+  const Gerrit = window.Gerrit;
+  if (Gerrit?.flushPreinstalls) {
+    Gerrit.flushPreinstalls();
+  }
+}
+export const _testOnly_flushPreinstalls = flushPreinstalls;
+
+export function initGerritPluginApi() {
+  window.Gerrit = window.Gerrit || {};
+  flushPreinstalls();
+  initGerritPluginsMethods(window.Gerrit as GerritGlobal);
+  // Preloaded plugins should be installed after Gerrit.install() is set,
+  // since plugin preloader substitutes Gerrit.install() temporarily.
+  // (Gerrit.install() is set in initGerritPluginsMethods)
+  getPluginLoader().installPreloadedPlugins();
+}
+
+export function _testOnly_initGerritPluginApi(): GerritGlobal {
+  window.Gerrit = window.Gerrit || {};
+  initGerritPluginApi();
+  return window.Gerrit as GerritGlobal;
+}
+
+export function deprecatedDelete(
+  url: string,
+  callback?: (response: Response) => void
+) {
+  console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
+  return getRestAPI()
+    .send(HttpMethod.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 (callback) callback(response);
+      return response;
+    });
+}
+
+function initGerritPluginsMethods(globalGerritObj: GerritGlobal) {
+  /**
+   * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
+   * the documentation how to replace it accordingly.
+   */
+  globalGerritObj.css = (rulesStr: string) => {
+    console.warn(
+      'Gerrit.css(rulesStr) is deprecated!',
+      'Use plugin.styles().css(rulesStr)'
+    );
+    if (!globalGerritObj._customStyleSheet) {
+      const styleEl = document.createElement('style');
+      document.head.appendChild(styleEl);
+      globalGerritObj._customStyleSheet = styleEl.sheet!;
+    }
+
+    const name = `__pg_js_api_class_${globalGerritObj._customStyleSheet.cssRules.length}`;
+    globalGerritObj._customStyleSheet.insertRule(
+      '.' + name + '{' + rulesStr + '}',
+      0
+    );
+    return name;
+  };
+
+  globalGerritObj.install = (callback, opt_version, opt_src) => {
+    getPluginLoader().install(callback, opt_version, opt_src);
+  };
+
+  globalGerritObj.getLoggedIn = () => {
+    console.warn(
+      'Gerrit.getLoggedIn() is deprecated! ' +
+        'Use plugin.restApi().getLoggedIn()'
+    );
+    return document.createElement('gr-rest-api-interface').getLoggedIn();
+  };
+
+  globalGerritObj.get = (
+    url: string,
+    callback?: (response: unknown) => void
+  ) => {
+    console.warn('.get() is deprecated! Use plugin.restApi().get()');
+    send(HttpMethod.GET, url, callback);
+  };
+
+  globalGerritObj.post = (
+    url: string,
+    payload?: RequestPayload,
+    callback?: (response: unknown) => void
+  ) => {
+    console.warn('.post() is deprecated! Use plugin.restApi().post()');
+    send(HttpMethod.POST, url, callback, payload);
+  };
+
+  globalGerritObj.put = (
+    url: string,
+    payload?: RequestPayload,
+    callback?: (response: unknown) => void
+  ) => {
+    console.warn('.put() is deprecated! Use plugin.restApi().put()');
+    send(HttpMethod.PUT, url, callback, payload);
+  };
+
+  globalGerritObj.delete = (
+    url: string,
+    callback?: (response: Response) => void
+  ) => {
+    deprecatedDelete(url, callback);
+  };
+
+  globalGerritObj.awaitPluginsLoaded = () => {
+    return getPluginLoader().awaitPluginsLoaded();
+  };
+
+  // TODO(taoalpha): consider removing these proxy methods
+  // and using getPluginLoader() directly
+  globalGerritObj._loadPlugins = (plugins, opt_option) => {
+    getPluginLoader().loadPlugins(plugins, opt_option);
+  };
+
+  globalGerritObj._arePluginsLoaded = () => {
+    return getPluginLoader().arePluginsLoaded();
+  };
+
+  globalGerritObj._isPluginPreloaded = url => {
+    return getPluginLoader().isPluginPreloaded(url);
+  };
+
+  globalGerritObj._isPluginEnabled = pathOrUrl => {
+    return getPluginLoader().isPluginEnabled(pathOrUrl);
+  };
+
+  globalGerritObj._isPluginLoaded = pathOrUrl => {
+    return getPluginLoader().isPluginLoaded(pathOrUrl);
+  };
+
+  const eventEmitter = appContext.eventEmitter;
+
+  // TODO(taoalpha): List all internal supported event names.
+  // Also convert this to inherited class once we move Gerrit to class.
+  globalGerritObj._eventEmitter = eventEmitter;
+  /**
+   * Enabling EventEmitter interface on Gerrit.
+   *
+   * This will enable to signal across different parts of js code without relying on DOM,
+   * including core to core, plugin to plugin and also core to plugin.
+   *
+   * @example
+   *
+   * // Emit this event from pluginA
+   * Gerrit.install(pluginA => {
+   *   fetch("some-api").then(() => {
+   *     Gerrit.on("your-special-event", {plugin: pluginA});
+   *   });
+   * });
+   *
+   * // Listen on your-special-event from pluignB
+   * Gerrit.install(pluginB => {
+   *   Gerrit.on("your-special-event", ({plugin}) => {
+   *     // do something, plugin is pluginA
+   *   });
+   * });
+   */
+  globalGerritObj.addListener = (eventName: string, cb: EventCallback) =>
+    eventEmitter.addListener(eventName, cb);
+  globalGerritObj.dispatch = (eventName: string, detail: any) =>
+    eventEmitter.dispatch(eventName, detail);
+  globalGerritObj.emit = (eventName: string, detail: any) =>
+    eventEmitter.emit(eventName, detail);
+  globalGerritObj.off = (eventName: string, cb: EventCallback) =>
+    eventEmitter.off(eventName, cb);
+  globalGerritObj.on = (eventName: string, cb: EventCallback) =>
+    eventEmitter.on(eventName, cb);
+  globalGerritObj.once = (eventName: string, cb: EventCallback) =>
+    eventEmitter.once(eventName, cb);
+  globalGerritObj.removeAllListeners = (eventName: string) =>
+    eventEmitter.removeAllListeners(eventName);
+  globalGerritObj.removeListener = (eventName: string, cb: EventCallback) =>
+    eventEmitter.removeListener(eventName, cb);
+}
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
deleted file mode 100644
index 2d87497..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
+++ /dev/null
@@ -1,105 +0,0 @@
-<!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-api-interface</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-js-api-interface></gr-js-api-interface>
-  </template>
-</test-fixture>
-
-<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';
-
-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'));
-    });
-
-    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'));
-    });
-
-    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-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
new file mode 100644
index 0000000..9312f1b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {getPluginLoader} from './gr-plugin-loader.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-js-api-interface');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-gerrit tests', () => {
+  let element;
+
+  let clock;
+  let sendStub;
+
+  setup(() => {
+    clock = sinon.useFakeTimers();
+
+    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  teardown(() => {
+    clock.restore();
+    element._removeEventCallbacks();
+    resetPlugins();
+  });
+
+  suite('proxy methods', () => {
+    test('Gerrit._isPluginEnabled proxy to getPluginLoader()', () => {
+      const stubFn = sinon.stub();
+      sinon.stub(
+          getPluginLoader(),
+          'isPluginEnabled')
+          .callsFake((...args) => stubFn(...args)
+          );
+      pluginApi._isPluginEnabled('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
+    });
+
+    test('Gerrit._isPluginLoaded proxy to getPluginLoader()', () => {
+      const stubFn = sinon.stub();
+      sinon.stub(
+          getPluginLoader(),
+          'isPluginLoaded')
+          .callsFake((...args) => stubFn(...args));
+      pluginApi._isPluginLoaded('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
+    });
+
+    test('Gerrit._isPluginPreloaded proxy to getPluginLoader()', () => {
+      const stubFn = sinon.stub();
+      sinon.stub(
+          getPluginLoader(),
+          'isPluginPreloaded')
+          .callsFake((...args) => stubFn(...args));
+      pluginApi._isPluginPreloaded('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
deleted file mode 100644
index 997e08c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
+++ /dev/null
@@ -1,323 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
new file mode 100644
index 0000000..736fac9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -0,0 +1,328 @@
+/**
+ * @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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {getPluginLoader} from './gr-plugin-loader';
+import {patchNumEquals} from '../../../utils/patch-set-util';
+import {customElement} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  LabelNameToValuesMap,
+  RevisionInfo,
+} from '../../../types/common';
+import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {GrAdminApi, MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+import {
+  JsApiService,
+  EventCallback,
+  ShowChangeDetail,
+  ShowRevisionActionsDetail,
+} from './gr-js-api-types';
+import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {DiffLayer, HighlightJS} from '../../../types/types';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
+
+const elements: {[key: string]: HTMLElement} = {};
+const eventCallbacks: {[key: string]: EventCallback[]} = {};
+
+@customElement('gr-js-api-interface')
+export class GrJsApiInterface
+  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  implements JsApiService {
+  handleEvent(type: EventType, detail: any) {
+    getPluginLoader()
+      .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: TargetElement, el: HTMLElement) {
+    elements[key] = el;
+  }
+
+  getElement(key: TargetElement) {
+    return elements[key];
+  }
+
+  addEventCallback(eventName: EventType, callback: EventCallback) {
+    if (!eventCallbacks[eventName]) {
+      eventCallbacks[eventName] = [];
+    }
+    eventCallbacks[eventName].push(callback);
+  }
+
+  canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null) {
+    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;
+  }
+
+  /** For testing only. */
+  _removeEventCallbacks() {
+    for (const type of Object.values(EventType)) {
+      eventCallbacks[type] = [];
+    }
+  }
+
+  // TODO(TS): The HISTORY event and its handler seem unused.
+  _handleHistory(detail: {path: string}) {
+    for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
+      try {
+        cb(detail.path);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleShowChange(detail: ShowChangeDetail) {
+    // 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 = {
+      ...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 (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);
+      }
+    }
+  }
+
+  _handleShowRevisionActions(detail: ShowRevisionActionsDetail) {
+    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: ChangeInfo | ParsedChangeInfo, msg: string) {
+    for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
+      try {
+        cb(change, msg);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  // TODO(TS): The COMMENT event and its handler seem unused.
+  _handleComment(detail: {node: Node}) {
+    for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
+      try {
+        cb(detail.node);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleLabelChange(detail: {change: ChangeInfo}) {
+    for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
+      try {
+        cb(detail.change);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleHighlightjsLoaded(detail: {hljs: HighlightJS}) {
+    for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
+      try {
+        cb(detail.hljs);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  modifyRevertMsg(change: ChangeInfo, revertMsg: string, origMsg: string) {
+    for (const cb of this._getEventCallbacks(EventType.REVERT)) {
+      try {
+        revertMsg = cb(change, revertMsg, origMsg) as string;
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    return revertMsg;
+  }
+
+  modifyRevertSubmissionMsg(
+    change: ChangeInfo,
+    revertSubmissionMsg: string,
+    origMsg: string
+  ) {
+    for (const cb of this._getEventCallbacks(EventType.REVERT_SUBMISSION)) {
+      try {
+        revertSubmissionMsg = cb(
+          change,
+          revertSubmissionMsg,
+          origMsg
+        ) as string;
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    return revertSubmissionMsg;
+  }
+
+  getDiffLayers(path: string, changeNum: number) {
+    const layers: DiffLayer[] = [];
+    for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+      const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+      try {
+        const layer = annotationApi.getLayer(path, changeNum);
+        layers.push(layer);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    return layers;
+  }
+
+  disposeDiffLayers(path: string) {
+    for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+      try {
+        const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+        annotationApi.disposeLayer(path);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  /**
+   * 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.
+   */
+  getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]> {
+    return getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        const providers: GrAnnotationActionsInterface[] = [];
+        this._getEventCallbacks(EventType.ANNOTATE_DIFF).forEach(cb => {
+          const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+          const provider = annotationApi.getCoverageProvider();
+          if (provider) providers.push(annotationApi);
+        });
+        return providers;
+      });
+  }
+
+  getAdminMenuLinks(): MenuLink[] {
+    const links: MenuLink[] = [];
+    for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
+      const adminApi = (cb as unknown) as GrAdminApi;
+      links.push(...adminApi.getMenuLinks());
+    }
+    return links;
+  }
+
+  getLabelValuesPostRevert(change?: ChangeInfo): LabelNameToValuesMap {
+    let labels: LabelNameToValuesMap = {};
+    for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
+      try {
+        labels = cb(change);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    return labels;
+  }
+
+  _getEventCallbacks(type: EventType) {
+    return eventCallbacks[type] || [];
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-js-api-interface': JsApiService & Element;
+  }
+}
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
deleted file mode 100644
index 1cdb20f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ /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.
- */
-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: 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!
-*/
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
new file mode 100644
index 0000000..b9a6ff4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
@@ -0,0 +1,20 @@
+/**
+ * @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 '../gr-rest-api-interface/gr-rest-api-interface';
+import './gr-js-api-interface-element';
+import './gr-public-js-api';
+import './gr-gerrit';
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
deleted file mode 100644
index ea1ac91..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ /dev/null
@@ -1,588 +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">
-<meta charset="utf-8">
-<title>gr-api-interface</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-js-api-interface></gr-js-api-interface>
-  </template>
-</test-fixture>
-
-<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 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(() => {
-      sandbox.stub(BaseUrlBehavior, 'getBaseUrl').returns('/r');
-
-      pluginApi.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(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('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(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('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);
-      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-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
new file mode 100644
index 0000000..b5c4e48
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -0,0 +1,454 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
+import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
+import {EventType} from '../../plugins/gr-plugin-types.js';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
+import {getPluginLoader} from './gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-js-api-interface');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-js-api-interface tests', () => {
+  let element;
+  let plugin;
+  let errorStub;
+
+  let getResponseObjectStub;
+  let sendStub;
+  let clock;
+
+  const throwErrFn = function() {
+    throw Error('Unfortunately, this handler has stopped');
+  };
+
+  setup(() => {
+    clock = sinon.useFakeTimers();
+
+    getResponseObjectStub = sinon.stub().returns(Promise.resolve());
+    sendStub = sinon.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 = basicFixture.instantiate();
+    errorStub = sinon.stub(console, 'error');
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    getPluginLoader().loadPlugins([]);
+  });
+
+  teardown(() => {
+    clock.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.equal(sendStub.lastCall.args[0], 'DELETE');
+      assert.equal(
+          sendStub.lastCall.args[1],
+          '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.equal(sendStub.lastCall.args[0], 'DELETE');
+      assert.equal(
+          sendStub.lastCall.args[1],
+          'http://test.com/plugins/testplugin/url'
+      );
+      assert.equal('text', err.message);
+    });
+  });
+
+  test('history event', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    plugin.on(EventType.HISTORY, throwErrFn);
+    plugin.on(EventType.HISTORY, resolve);
+    element.handleEvent(EventType.HISTORY, {path: '/path/to/awesomesauce'});
+    const path = await promise;
+    assert.equal(path, '/path/to/awesomesauce');
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('showchange event', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    const expectedChange = {mergeable: false, ...testChange};
+    plugin.on(EventType.SHOW_CHANGE, throwErrFn);
+    plugin.on(EventType.SHOW_CHANGE, (change, revision, info) => {
+      resolve({change, revision, info});
+    });
+    element.handleEvent(EventType.SHOW_CHANGE,
+        {change: testChange, patchNum: 1, info: {mergeable: false}});
+
+    const {change, revision, info} = await promise;
+    assert.deepEqual(change, expectedChange);
+    assert.deepEqual(revision, testChange.revisions.abc);
+    assert.deepEqual(info, {mergeable: false});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('show-revision-actions event', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    plugin.on(EventType.SHOW_REVISION_ACTIONS, throwErrFn);
+    plugin.on(EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
+      resolve({change, actions});
+    });
+    element.handleEvent(EventType.SHOW_REVISION_ACTIONS,
+        {change: testChange, revisionActions: {test: {}}});
+
+    const {change, actions} = await promise;
+    assert.deepEqual(change, testChange);
+    assert.deepEqual(actions, {test: {}});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('handleEvent awaits plugins load', async () => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    const spy = sinon.spy();
+    getPluginLoader().loadPlugins(['plugins/test.html']);
+    plugin.on(EventType.SHOW_CHANGE, spy);
+    element.handleEvent(EventType.SHOW_CHANGE,
+        {change: testChange, patchNum: 1});
+    assert.isFalse(spy.called);
+
+    // Timeout on loading plugins
+    clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    await flush();
+    assert.isTrue(spy.called);
+  });
+
+  test('comment event', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    const testCommentNode = {foo: 'bar'};
+    plugin.on(EventType.COMMENT, throwErrFn);
+    plugin.on(EventType.COMMENT, resolve);
+    element.handleEvent(EventType.COMMENT, {node: testCommentNode});
+
+    const commentNode = await promise;
+    assert.deepEqual(commentNode, testCommentNode);
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  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(EventType.REVERT, throwErrFn);
+    plugin.on(EventType.REVERT, appendToRevertMsg);
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+        'test\n> origTest\ninfo');
+    assert.isTrue(errorStub.calledOnce);
+
+    plugin.on(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(EventType.POST_REVERT, throwErrFn);
+    plugin.on(EventType.POST_REVERT, getLabels);
+    assert.deepEqual(
+        element.getLabelValuesPostRevert(null), {'Code-Review': 1});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('commitmsgedit event', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    const testMsg = 'Test CL commit message';
+    plugin.on(EventType.COMMIT_MSG_EDIT, throwErrFn);
+    plugin.on(EventType.COMMIT_MSG_EDIT, (change, msg) => {
+      resolve(msg);
+    });
+    element.handleCommitMessage(null, testMsg);
+
+    const msg = await promise;
+    assert.deepEqual(msg, testMsg);
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('labelchange event', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    const testChange = {_number: 42};
+    plugin.on(EventType.LABEL_CHANGE, throwErrFn);
+    plugin.on(EventType.LABEL_CHANGE, resolve);
+    element.handleEvent(EventType.LABEL_CHANGE, {change: testChange});
+
+    const change = await promise;
+    assert.deepEqual(change, testChange);
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('submitchange', () => {
+    plugin.on(EventType.SUBMIT_CHANGE, throwErrFn);
+    plugin.on(EventType.SUBMIT_CHANGE, () => true);
+    assert.isTrue(element.canSubmitChange());
+    assert.isTrue(errorStub.calledOnce);
+    plugin.on(EventType.SUBMIT_CHANGE, () => false);
+    plugin.on(EventType.SUBMIT_CHANGE, () => true);
+    assert.isFalse(element.canSubmitChange());
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('highlightjs-loaded event', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    const testHljs = {_number: 42};
+    plugin.on(EventType.HIGHLIGHTJS_LOADED, throwErrFn);
+    plugin.on(EventType.HIGHLIGHTJS_LOADED, resolve);
+    element.handleEvent(EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
+
+    const hljs = await promise;
+    assert.deepEqual(hljs, testHljs);
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('getLoggedIn', () => {
+    // fake fetch for authCheck
+    sinon.stub(window, 'fetch').callsFake(() => Promise.resolve({status: 204}));
+    return plugin.restApi().getLoggedIn()
+        .then(loggedIn => {
+          assert.isTrue(loggedIn);
+        });
+  });
+
+  test('attributeHelper', () => {
+    assert.isOk(plugin.attributeHelper());
+  });
+
+  test('getAdminMenuLinks', () => {
+    const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
+    const getCallbacksStub = sinon.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],
+        EventType.ADMIN_MENU_LINKS);
+  });
+
+  suite('test plugin with base url', () => {
+    let baseUrlPlugin;
+
+    setup(() => {
+      stubBaseUrl('/r');
+
+      pluginApi.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 = sinon.stub(GrPopupInterface.prototype, 'open').callsFake(
+          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);
+    });
+  });
+
+  suite('screen', () => {
+    test('screenUrl()', () => {
+      stubBaseUrl('/base');
+      assert.equal(
+          plugin.screenUrl(),
+          `${location.origin}/base/x/testplugin`
+      );
+      assert.equal(
+          plugin.screenUrl('foo'),
+          `${location.origin}/base/x/testplugin/foo`
+      );
+    });
+
+    test('works', () => {
+      sinon.stub(plugin, 'registerCustomComponent');
+      plugin.screen('foo', 'some-module');
+      assert.isTrue(plugin.registerCustomComponent.calledWith(
+          'testplugin-screen-foo', 'some-module'));
+    });
+  });
+
+  suite('settingsScreen', () => {
+    test('plugin.settings() returns GrSettingsApi', () => {
+      assert.isOk(plugin.settings());
+      assert.isTrue(plugin.settings() instanceof GrSettingsApi);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
new file mode 100644
index 0000000..6456370
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.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 {ActionInfo, ChangeInfo, PatchSetNum} from '../../../types/common';
+import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {DiffLayer} from '../../../types/types';
+import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+
+export interface ShowChangeDetail {
+  change: ChangeInfo;
+  patchNum: PatchSetNum;
+  info: {mergeable: boolean};
+}
+
+export interface ShowRevisionActionsDetail {
+  change: ChangeInfo;
+  revisionActions: {[key: string]: ActionInfo};
+}
+
+export type EventCallback = (...args: any[]) => any;
+
+export interface JsApiService {
+  getElement(key: TargetElement): HTMLElement;
+  addEventCallback(eventName: EventType, callback: EventCallback): void;
+  modifyRevertSubmissionMsg(
+    change: ChangeInfo,
+    revertSubmissionMsg: string,
+    origMsg: string
+  ): string;
+  handleEvent(eventName: EventType, detail: any): void;
+  modifyRevertMsg(
+    change: ChangeInfo,
+    revertMsg: string,
+    origMsg: string
+  ): string;
+  addElement(key: TargetElement, el: HTMLElement): void;
+  getDiffLayers(path: string, changeNum: number): DiffLayer[];
+  disposeDiffLayers(path: string): void;
+  getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]>;
+  getAdminMenuLinks(): MenuLink[];
+  // TODO(TS): Add more methods when needed for the TS conversion.
+}
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
deleted file mode 100644
index e3256a1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
+++ /dev/null
@@ -1,102 +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.
- */
-export 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.refresh = function() {
-  window.location.reload();
-};
-
-GrPluginActionContext.prototype.textfield = function() {
-  return document.createElement('paper-input');
-};
-
-GrPluginActionContext.prototype.br = function() {
-  return document.createElement('br');
-};
-
-GrPluginActionContext.prototype.msg = function(text) {
-  const label = document.createElement('gr-label');
-  Polymer.dom(label).appendChild(document.createTextNode(text));
-  return label;
-};
-
-GrPluginActionContext.prototype.div = function(...els) {
-  const div = document.createElement('div');
-  for (const el of els) {
-    Polymer.dom(div).appendChild(el);
-  }
-  return div;
-};
-
-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.checkbox = function() {
-  const checkbox = document.createElement('input');
-  checkbox.type = 'checkbox';
-  return checkbox;
-};
-
-GrPluginActionContext.prototype.label = function(checkbox, title) {
-  return this.div(checkbox, this.msg(title));
-};
-
-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}`,
-          },
-        }));
-      });
-};
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
new file mode 100644
index 0000000..ffdf710
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -0,0 +1,128 @@
+/**
+ * @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 {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
+import {PluginApi} from '../../plugins/gr-plugin-types';
+import {UIActionInfo} from './gr-change-actions-js-api';
+
+interface GrPopupInterface {
+  close(): void;
+}
+
+interface ButtonCallBacks {
+  onclick: (event: Event) => boolean;
+}
+
+export class GrPluginActionContext {
+  private _popups: GrPopupInterface[] = [];
+
+  constructor(
+    public readonly plugin: PluginApi,
+    public readonly action: UIActionInfo,
+    public readonly change: ChangeInfo,
+    public readonly revision: RevisionInfo
+  ) {}
+
+  popup(element: Node) {
+    this.plugin.popup().then(popApi => {
+      const popupEl = popApi._getElement();
+      if (!popupEl) {
+        throw new Error('Popup element not found');
+      }
+      popupEl.appendChild(element);
+      this._popups.push(popApi);
+    });
+  }
+
+  hide() {
+    for (const popupApi of this._popups) {
+      popupApi.close();
+    }
+    this._popups.splice(0);
+  }
+
+  refresh() {
+    window.location.reload();
+  }
+
+  textfield(): HTMLElement {
+    return document.createElement('paper-input');
+  }
+
+  br() {
+    return document.createElement('br');
+  }
+
+  msg(text: string) {
+    const label = document.createElement('gr-label');
+    label.appendChild(document.createTextNode(text));
+    return label;
+  }
+
+  div(...els: Node[]) {
+    const div = document.createElement('div');
+    for (const el of els) {
+      div.appendChild(el);
+    }
+    return div;
+  }
+
+  button(label: string, callbacks: ButtonCallBacks | undefined) {
+    const onClick = callbacks && callbacks.onclick;
+    const button = document.createElement('gr-button');
+    button.appendChild(document.createTextNode(label));
+    if (onClick) {
+      this.plugin.eventHelper(button).onTap(onClick);
+    }
+    return button;
+  }
+
+  checkbox() {
+    const checkbox = document.createElement('input');
+    checkbox.type = 'checkbox';
+    return checkbox;
+  }
+
+  label(checkbox: Node, title: string) {
+    return this.div(checkbox, this.msg(title));
+  }
+
+  prependLabel(title: string, checkbox: Node) {
+    return this.label(checkbox, title);
+  }
+
+  call(payload: RequestPayload, onSuccess: (result: unknown) => void) {
+    if (!this.action.method) return;
+    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: unknown) => {
+        document.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {
+              message: `Plugin network error: ${error}`,
+            },
+          })
+        );
+      });
+  }
+}
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
deleted file mode 100644
index 08c784ab..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
+++ /dev/null
@@ -1,163 +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">
-<meta charset="utf-8">
-<title>gr-plugin-action-context</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>
-    <div></div>
-  </template>
-</test-fixture>
-
-<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';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-action-context tests', () => {
-  let instance;
-  let sandbox;
-  let plugin;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    instance = new GrPluginActionContext(plugin);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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));
-
-    instance.hide();
-    assert.isTrue(popupApiStub.close.called);
-  });
-
-  test('textfield', () => {
-    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
-  });
-
-  test('br', () => {
-    assert.equal(instance.br().tagName, 'BR');
-  });
-
-  test('msg', () => {
-    const el = instance.msg('foobar');
-    assert.equal(el.tagName, 'GR-LABEL');
-    assert.equal(el.textContent, 'foobar');
-  });
-
-  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('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-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
new file mode 100644
index 0000000..e764bf8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -0,0 +1,148 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {GrPluginActionContext} from './gr-plugin-action-context.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-plugin-action-context tests', () => {
+  let instance;
+
+  let plugin;
+
+  setup(() => {
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrPluginActionContext(plugin);
+  });
+
+  test('popup() and hide()', async () => {
+    const popupApiStub = {
+      _getElement: sinon.stub().returns(document.createElement('div')),
+      close: sinon.stub(),
+    };
+    sinon.stub(plugin, 'popup').returns(Promise.resolve(popupApiStub));
+    const el = document.createElement('span');
+    instance.popup(el);
+    await flush();
+    assert.isTrue(popupApiStub._getElement.called);
+    instance.hide();
+    assert.isTrue(popupApiStub.close.called);
+  });
+
+  test('textfield', () => {
+    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
+  });
+
+  test('br', () => {
+    assert.equal(instance.br().tagName, 'BR');
+  });
+
+  test('msg', () => {
+    const el = instance.msg('foobar');
+    assert.equal(el.tagName, 'GR-LABEL');
+    assert.equal(el.textContent, 'foobar');
+  });
+
+  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');
+  });
+
+  suite('button', () => {
+    let clickStub;
+    let button;
+    setup(() => {
+      clickStub = sinon.stub();
+      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.
+      document.body.appendChild(button);
+    });
+
+    test('click', () => {
+      MockInteractions.tap(button);
+      flush();
+      assert.isTrue(clickStub.called);
+      assert.equal(button.textContent, 'foo');
+    });
+
+    teardown(() => {
+      button.remove();
+    });
+  });
+
+  test('checkbox', () => {
+    const el = instance.checkbox();
+    assert.equal(el.tagName, 'INPUT');
+    assert.equal(el.type, 'checkbox');
+  });
+
+  test('label', () => {
+    const fakeMsg = {};
+    const fakeCheckbox = {};
+    sinon.stub(instance, 'div');
+    sinon.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 = sinon.stub().returns(Promise.resolve());
+    sinon.stub(plugin, 'restApi').returns({
+      send: sendStub,
+    });
+    const payload = {foo: 'foo'};
+    const successStub = sinon.stub();
+    instance.call(payload, successStub);
+    assert.isTrue(sendStub.calledWith(
+        'METHOD', '/changes/1/revisions/2/foo~bar', payload));
+  });
+
+  test('call error', async () => {
+    instance.action = {
+      method: 'METHOD',
+      __key: 'key',
+      __url: '/changes/1/revisions/2/foo~bar',
+    };
+    const sendStub = sinon.stub().returns(Promise.reject(new Error('boom')));
+    sinon.stub(plugin, 'restApi').returns({
+      send: sendStub,
+    });
+    const errorStub = sinon.stub();
+    document.addEventListener('show-alert', errorStub);
+    instance.call();
+    await flush();
+    assert.isTrue(errorStub.calledOnce);
+    assert.equal(errorStub.args[0][0].detail.message,
+        'Plugin network error: Error: boom');
+  });
+});
+
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
deleted file mode 100644
index 0727397..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ /dev/null
@@ -1,169 +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.
- */
-
-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.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;
-  }
-};
-
-/**
- * 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));
-  }
-};
-
-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 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)));
-};
-
-// 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.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
new file mode 100644
index 0000000..3935ef1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -0,0 +1,226 @@
+/**
+ * @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 {importHref} from '../../../scripts/import-href';
+import {HookApi, PluginApi} from '../../plugins/gr-plugin-types';
+import {notUndefined} from '../../../types/types';
+
+type Callback = (value: any) => void;
+
+export interface ModuleInfo {
+  moduleName: string;
+  plugin: PluginApi;
+  pluginUrl?: URL;
+  type?: string;
+  domHook?: HookApi;
+  slot?: string;
+}
+
+interface Options {
+  endpoint: string;
+  dynamicEndpoint?: string;
+  slot?: string;
+  type?: string;
+  moduleName?: string;
+  domHook?: HookApi;
+}
+
+export class GrPluginEndpoints {
+  private readonly _endpoints = new Map<string, ModuleInfo[]>();
+
+  private readonly _callbacks = new Map<string, ((value: any) => void)[]>();
+
+  private readonly _dynamicPlugins = new Map<string, Set<string>>();
+
+  private readonly _importedUrls = new Set<string>();
+
+  private _pluginLoaded = false;
+
+  setPluginsReady() {
+    this._pluginLoaded = true;
+  }
+
+  onNewEndpoint(endpoint: string, callback: Callback) {
+    if (!this._callbacks.has(endpoint)) {
+      this._callbacks.set(endpoint, []);
+    }
+    this._callbacks.get(endpoint)!.push(callback);
+  }
+
+  onDetachedEndpoint(endpoint: string, callback: Callback) {
+    if (this._callbacks.has(endpoint)) {
+      const filteredCallbacks = this._callbacks
+        .get(endpoint)!
+        .filter((cb: Callback) => cb !== callback);
+      this._callbacks.set(endpoint, filteredCallbacks);
+    }
+  }
+
+  _getOrCreateModuleInfo(plugin: PluginApi, opts: Options): ModuleInfo {
+    const {endpoint, slot, type, moduleName, domHook} = opts;
+    const existingModule = this._endpoints
+      .get(endpoint!)!
+      .find(
+        (info: ModuleInfo) =>
+          info.plugin === plugin &&
+          info.moduleName === moduleName &&
+          info.domHook === domHook &&
+          info.slot === slot
+      );
+    if (existingModule) {
+      return existingModule;
+    } else {
+      const newModule: ModuleInfo = {
+        moduleName: moduleName!,
+        plugin,
+        pluginUrl: plugin._url,
+        type,
+        domHook,
+        slot,
+      };
+      this._endpoints.get(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.
+   */
+  registerModule(plugin: PluginApi, opts: Options) {
+    const endpoint = opts.endpoint!;
+    const dynamicEndpoint = opts.dynamicEndpoint;
+    if (dynamicEndpoint) {
+      if (!this._dynamicPlugins.has(dynamicEndpoint)) {
+        this._dynamicPlugins.set(dynamicEndpoint, new Set());
+      }
+      this._dynamicPlugins.get(dynamicEndpoint)!.add(endpoint);
+    }
+    if (!this._endpoints.has(endpoint)) {
+      this._endpoints.set(endpoint, []);
+    }
+    const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
+    // TODO: the logic below seems wrong when:
+    // multiple plugins register to the same endpoint
+    // one register before plugins ready
+    // the other done after, then only the later one will have the callbacks
+    // invoked.
+    if (this._pluginLoaded && this._callbacks.has(endpoint)) {
+      this._callbacks.get(endpoint)!.forEach(callback => callback(moduleInfo));
+    }
+  }
+
+  getDynamicEndpoints(dynamicEndpoint: string): string[] {
+    const plugins = this._dynamicPlugins.get(dynamicEndpoint);
+    if (!plugins) return [];
+    return Array.from(plugins);
+  }
+
+  /**
+   * Get detailed information about modules registered with an extension
+   * endpoint.
+   */
+  getDetails(name: string, options?: Options): ModuleInfo[] {
+    const type = options && options.type;
+    const moduleName = options && options.moduleName;
+    if (!this._endpoints.has(name)) {
+      return [];
+    } else {
+      return this._endpoints
+        .get(name)!
+        .filter(
+          (item: ModuleInfo) =>
+            (!type || item.type === type) &&
+            (!moduleName || moduleName === item.moduleName)
+        );
+    }
+  }
+
+  /**
+   * Get detailed module names for instantiating at the endpoint.
+   */
+  getModules(name: string, options?: Options): string[] {
+    const modulesData = this.getDetails(name, options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return modulesData.map(m => m.moduleName);
+  }
+
+  /**
+   * Get plugin URLs with element and module definitions.
+   */
+  getPlugins(name: string, options?: Options): URL[] {
+    const modulesData = this.getDetails(name, options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return Array.from(new Set(modulesData.map(m => m.pluginUrl))).filter(
+      notUndefined
+    );
+  }
+
+  importUrl(pluginUrl: URL) {
+    let timerId: any;
+    return Promise.race([
+      new Promise((resolve, reject) => {
+        this._importedUrls.add(pluginUrl.href);
+        importHref(pluginUrl.href, resolve, reject);
+      }),
+      // Timeout after 3s
+      new Promise(r => (timerId = setTimeout(r, 3000))),
+    ]).finally(() => {
+      if (timerId) clearTimeout(timerId);
+    });
+  }
+
+  /**
+   * Get plugin URLs with element and module definitions.
+   */
+  getAndImportPlugins(name: string, options?: Options) {
+    return Promise.all(
+      this.getPlugins(name, options).map(pluginUrl => {
+        if (this._importedUrls.has(pluginUrl.href)) {
+          return Promise.resolve();
+        }
+
+        // TODO: we will deprecate html plugins entirely
+        // for now, keep the original behavior and import
+        // only for html ones
+        if (pluginUrl?.pathname.endsWith('.html')) {
+          return this.importUrl(pluginUrl);
+        } else {
+          return Promise.resolve();
+        }
+      })
+    );
+  }
+}
+
+// TODO(dmfilippov): Convert to service and add to appContext
+let pluginEndpoints = new GrPluginEndpoints();
+
+// To avoid mutable-exports, we don't want to export above variable directly
+export function getPluginEndpoints() {
+  return pluginEndpoints;
+}
+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
deleted file mode 100644
index 3494e99..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ /dev/null
@@ -1,180 +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">
-<meta charset="utf-8">
-<title>gr-plugin-endpoints</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 './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';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-endpoints tests', () => {
-  let sandbox;
-  let instance;
-  let pluginFoo;
-  let pluginBar;
-  let domHook;
-
-  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,
-        {
-          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,
-        {
-          endpoint: 'a-place',
-          type: 'style',
-          moduleName: 'bar-module',
-          domHook,
-        }
-    );
-    sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('getDetails all', () => {
-    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,
-      },
-    ]);
-  });
-
-  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,
-        {
-          endpoint: 'a-place',
-          type: 'replace',
-          moduleName: 'zaz-module',
-          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-endpoints_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
new file mode 100644
index 0000000..5b931b4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.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 '../../../test/common-test-setup-karma.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import './gr-js-api-interface.js';
+import {GrPluginEndpoints} from './gr-plugin-endpoints.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-plugin-endpoints tests', () => {
+  let instance;
+  let pluginFoo;
+  let pluginBar;
+  let domHook;
+
+  setup(() => {
+    domHook = {};
+    instance = new GrPluginEndpoints();
+    pluginApi.install(p => { pluginFoo = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/foo.html');
+    instance.registerModule(
+        pluginFoo,
+        {
+          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,
+        {
+          endpoint: 'a-place',
+          type: 'style',
+          moduleName: 'bar-module',
+          domHook,
+        }
+    );
+    sinon.spy(instance, 'importUrl');
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('getDetails all', () => {
+    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,
+      },
+    ]);
+  });
+
+  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('getAndImportPlugins', () => {
+    instance.getAndImportPlugins('a-place');
+    assert.isTrue(instance.importUrl.called);
+    assert.isTrue(instance.importUrl.calledOnce);
+    instance.getAndImportPlugins('a-place');
+    assert.isTrue(instance.importUrl.calledOnce);
+  });
+
+  test('onNewEndpoint', () => {
+    const newModuleStub = sinon.stub();
+    instance.setPluginsReady();
+    instance.onNewEndpoint('a-place', newModuleStub);
+    instance.registerModule(
+        pluginFoo,
+        {
+          endpoint: 'a-place',
+          type: 'replace',
+          moduleName: 'zaz-module',
+          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,
+      },
+    ]);
+  });
+});
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
deleted file mode 100644
index 2f27304..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
+++ /dev/null
@@ -1,445 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import './gr-api-utils.js';
-
-import {
-  PLUGIN_LOADING_TIMEOUT_MS,
-  PRELOADED_PROTOCOL,
-  getPluginNameFromUrl,
-  getBaseUrl,
-} from './gr-api-utils.js';
-
-/**
- * @enum {string}
- */
-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';
-
-/**
- * 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.
-   *
-   * @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, window.ASSETS_PATH);
-      // Skip if preloaded, for bundling.
-      if (this.isPluginPreloaded(url)) return;
-
-      const pluginKey = this._getPluginKeyFromUrl(url);
-      // Skip if already installed.
-      if (this._plugins.has(pluginKey)) return;
-      this._plugins.set(pluginKey, {
-        name: pluginKey,
-        url,
-        state: PluginState.PENDING,
-        plugin: null,
-      });
-
-      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);
-      }
-    });
-
-    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;
-      }
-    }
-
-    return url.pathname && url.pathname.endsWith(suffix);
-  }
-
-  _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 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);
-      }
-    }
-    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;
-    }
-  }
-
-  /**
-   * 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);
-  }
-
-  /**
-   * 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 = {}) {
-    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.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
new file mode 100644
index 0000000..47b7be3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -0,0 +1,455 @@
+/**
+ * @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 {appContext} from '../../../services/app-context';
+import {importHref} from '../../../scripts/import-href';
+import {
+  PLUGIN_LOADING_TIMEOUT_MS,
+  PRELOADED_PROTOCOL,
+  getPluginNameFromUrl,
+} from './gr-api-utils';
+import {Plugin} from './gr-public-js-api';
+import {getBaseUrl} from '../../../utils/url-util';
+import {getPluginEndpoints} from './gr-plugin-endpoints';
+import {PluginApi} from '../../plugins/gr-plugin-types';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+enum 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 failed to load. */
+  LOAD_FAILED = 'LOAD_FAILED',
+}
+
+interface PluginObject {
+  name: string;
+  url: string;
+  state: PluginState;
+  plugin: PluginApi | null;
+}
+
+interface PluginOption {
+  sync?: boolean;
+}
+
+export interface PluginOptionMap {
+  [path: string]: PluginOption;
+}
+
+type GerritScriptElement = HTMLScriptElement & {
+  __importElement: HTMLScriptElement;
+};
+
+type PluginCallback = (plugin: PluginApi) => void;
+
+interface PluginCallbackMap {
+  [name: string]: PluginCallback;
+}
+
+interface GerritGlobal {
+  _preloadedPlugins?: PluginCallbackMap;
+}
+
+// Prefix for any unrecognized plugin urls.
+// Url should match following patterns:
+// /plugins/PLUGINNAME/static/SCRIPTNAME.(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 {
+  _pluginListLoaded = false;
+
+  _plugins = new Map<string, PluginObject>();
+
+  _reporting: ReportingService | null = null;
+
+  // Promise that resolves when all plugins loaded
+  _loadingPromise: Promise<void> | null = null;
+
+  // Resolver to resolve _loadingPromise once all plugins loaded
+  _loadingResolver: (() => void) | null = null;
+
+  _getReporting() {
+    if (!this._reporting) {
+      this._reporting = appContext.reportingService;
+    }
+    return this._reporting;
+  }
+
+  /**
+   * Use the plugin name or use the full url if not recognized.
+   */
+  _getPluginKeyFromUrl(url: string) {
+    return getPluginNameFromUrl(url) || `${UNKNOWN_PLUGIN_PREFIX}${url}`;
+  }
+
+  /**
+   * Load multiple plugins with certain options.
+   */
+  loadPlugins(plugins: string[] = [], opts: PluginOptionMap = {}) {
+    this._pluginListLoaded = true;
+
+    plugins.forEach(path => {
+      const url = this._urlFor(path, window.ASSETS_PATH);
+      // Skip if preloaded, for bundling.
+      if (this.isPluginPreloaded(url)) return;
+
+      const pluginKey = this._getPluginKeyFromUrl(url);
+      // Skip if already installed.
+      if (this._plugins.has(pluginKey)) return;
+      this._plugins.set(pluginKey, {
+        name: pluginKey,
+        url,
+        state: PluginState.PENDING,
+        plugin: null,
+      });
+
+      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);
+      }
+    });
+
+    this.awaitPluginsLoaded().then(() => {
+      console.info('Plugins loaded');
+      this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
+    });
+  }
+
+  _isPathEndsWith(url: string | URL, suffix: string) {
+    if (!(url instanceof URL)) {
+      try {
+        url = new URL(url);
+      } catch (e) {
+        console.warn(e);
+        return false;
+      }
+    }
+
+    return url.pathname && url.pathname.endsWith(suffix);
+  }
+
+  _getAllInstalledPluginNames() {
+    const installedPlugins = [];
+    for (const plugin of this._plugins.values()) {
+      if (plugin.state === PluginState.LOADED) {
+        installedPlugins.push(plugin.name);
+      }
+    }
+    return installedPlugins;
+  }
+
+  install(
+    callback: (plugin: PluginApi) => void,
+    version?: string,
+    src?: string
+  ) {
+    // HTML import polyfill adds __importElement pointing to the import tag.
+    const gerritScript = document.currentScript as GerritScriptElement | null;
+    const script = gerritScript?.__importElement ?? gerritScript;
+    if (!src && script && script.src) {
+      src = script.src;
+    }
+    if ((!src || src.startsWith('data:')) && script && script.baseURI) {
+      src = script && script.baseURI;
+    }
+    if (!src) {
+      this._failToLoad('Failed to determine src.');
+      return;
+    }
+    if (version && version !== API_VERSION) {
+      this._failToLoad(
+        `Plugin ${src} install error: only version ${API_VERSION} is supported in PolyGerrit. ${version} was given.`,
+        src
+      );
+      return;
+    }
+
+    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()) {
+      getPluginEndpoints().setPluginsReady();
+      if (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: string, pluginUrl?: string) {
+    // Show an alert with the error
+    document.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {
+          message: `Plugin install error: ${message} from ${pluginUrl}`,
+        },
+      })
+    );
+    if (pluginUrl) this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
+    this._checkIfCompleted();
+  }
+
+  _updatePluginState(pluginUrl: string, state: PluginState): PluginObject {
+    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.info(`Plugin loaded separately: ${pluginUrl}`);
+      this._plugins.set(key, {
+        name: key,
+        url: pluginUrl,
+        state,
+        plugin: null,
+      });
+    }
+    return this._plugins.get(key)!;
+  }
+
+  _pluginInstalled(url: string, plugin: PluginApi) {
+    const pluginObj = this._updatePluginState(url, PluginState.LOADED);
+    pluginObj.plugin = plugin;
+    this._getReporting().pluginLoaded(plugin.getPluginName() || url);
+    console.info(`Plugin ${plugin.getPluginName() || url} installed.`);
+    this._checkIfCompleted();
+  }
+
+  installPreloadedPlugins() {
+    const Gerrit = window.Gerrit as GerritGlobal;
+    if (!Gerrit || !Gerrit._preloadedPlugins) {
+      return;
+    }
+    for (const name of Object.keys(Gerrit._preloadedPlugins)) {
+      const callback = Gerrit._preloadedPlugins[name];
+      this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
+    }
+  }
+
+  isPluginPreloaded(pathOrUrl: string) {
+    const url = this._urlFor(pathOrUrl);
+    const name = getPluginNameFromUrl(url);
+    const Gerrit = window.Gerrit as GerritGlobal;
+    if (name && Gerrit?._preloadedPlugins) {
+      return hasOwnProperty(Gerrit._preloadedPlugins, name);
+    } else {
+      return false;
+    }
+  }
+
+  /**
+   * Checks if given plugin path/url is enabled or not.
+   */
+  isPluginEnabled(pathOrUrl: string) {
+    const url = this._urlFor(pathOrUrl);
+    if (this.isPluginPreloaded(url)) return true;
+    const key = this._getPluginKeyFromUrl(url);
+    return this._plugins.has(key);
+  }
+
+  /**
+   * Returns the plugin object with a given url.
+   */
+  getPlugin(pathOrUrl: string) {
+    const url = this._urlFor(pathOrUrl);
+    const key = this._getPluginKeyFromUrl(url);
+    return this._plugins.get(key);
+  }
+
+  /**
+   * Checks if given plugin path/url is loaded or not.
+   */
+  isPluginLoaded(pathOrUrl: string): boolean {
+    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: string, opts: PluginOption = {}) {
+    const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
+    const urlWithoutAP = this._urlFor(pluginUrl);
+    let onerror = undefined;
+    if (urlWithAP !== urlWithoutAP) {
+      onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync);
+    }
+    this._loadHtmlPlugin(urlWithAP, opts.sync, onerror);
+  }
+
+  _loadHtmlPlugin(url: string, sync?: boolean, onerror?: (e: Event) => void) {
+    if (!onerror) {
+      onerror = () => {
+        this._failToLoad(`${url} import error`, url);
+      };
+    }
+
+    importHref(url, () => {}, onerror, !sync);
+  }
+
+  _loadJsPlugin(pluginUrl: string) {
+    const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
+    const urlWithoutAP = this._urlFor(pluginUrl);
+    let onerror = undefined;
+    if (urlWithAP !== urlWithoutAP) {
+      onerror = () => this._createScriptTag(urlWithoutAP);
+    }
+
+    this._createScriptTag(urlWithAP, onerror);
+  }
+
+  _createScriptTag(url: string, onerror?: OnErrorEventHandler) {
+    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: string, assetsPath?: string): string {
+    // theme is per host, should always load from assetsPath
+    const isThemeFile =
+      pathOrUrl.endsWith('static/gerrit-theme.html') ||
+      pathOrUrl.endsWith('static/gerrit-theme.js');
+    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 &&
+        assetsPath
+      ) {
+        // 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 && assetsPath) {
+      return assetsPath + pathOrUrl;
+    }
+
+    return window.location.origin + getBaseUrl() + pathOrUrl;
+  }
+
+  awaitPluginsLoaded() {
+    // Resolve if completed.
+    this._checkIfCompleted();
+
+    if (this.arePluginsLoaded()) {
+      return Promise.resolve();
+    }
+    if (!this._loadingPromise) {
+      // TODO(TS): Should be a number, but TS thinks that is must be some weird
+      // NodeJS.Timeout object.
+      let timerId: any;
+      this._loadingPromise = Promise.race([
+        new Promise(resolve => (this._loadingResolver = resolve)),
+        new Promise(
+          (_, reject) =>
+            (timerId = setTimeout(() => {
+              reject(new Error(this._timeout()));
+            }, PLUGIN_LOADING_TIMEOUT_MS))
+        ),
+      ]).finally(() => {
+        if (timerId) clearTimeout(timerId);
+      }) as Promise<void>;
+    }
+    return this._loadingPromise;
+  }
+}
+
+// TODO(dmfilippov): Convert to service and add to appContext
+let pluginLoader = new PluginLoader();
+export function _testOnly_resetPluginLoader() {
+  pluginLoader = new PluginLoader();
+  return pluginLoader;
+}
+
+export function getPluginLoader() {
+  return 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
deleted file mode 100644
index c972f53..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
+++ /dev/null
@@ -1,570 +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">
-<meta charset="utf-8">
-<title>gr-plugin-host</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-js-api-interface></gr-js-api-interface>
-  </template>
-</test-fixture>
-
-<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(() => {
-      importHtmlPluginStub = sandbox.stub();
-      sandbox.stub(pluginLoader, '_loadHtmlPlugin', url => {
-        importHtmlPluginStub(url);
-      });
-      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(() => {
-      window.ASSETS_PATH = '';
-    });
-
-    test('Should try load plugins from assets path instead', () => {
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-        'foo/bar.html',
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
-      );
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
-    });
-
-    test('Should honor original path if exists', () => {
-      pluginLoader.loadPlugins([
-        'http://e.com/foo/bar.html',
-        'http://e.com/foo/bar.js',
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
-      );
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
-    });
-
-    test('Should try replace current host with assetsPath', () => {
-      const host = window.location.origin;
-      pluginLoader.loadPlugins([
-        `${host}/foo/bar.html`,
-        `${host}/foo/bar.js`,
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
-      );
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
-    });
-  });
-
-  test('adds js plugins will call the body', () => {
-    pluginLoader.loadPlugins([
-      'http://e.com/foo/bar.js',
-      'http://e.com/bar/foo.js',
-    ]);
-    assert.isTrue(document.body.appendChild.calledTwice);
-  });
-
-  test('can call awaitPluginsLoaded multiple times', done => {
-    const plugins = [
-      '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(pluginLoader, '_loadJsPlugin', url => {
-      pluginApi.install(() => pluginCallback(url), undefined, url);
-    });
-
-    pluginLoader.loadPlugins(plugins);
-
-    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-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
new file mode 100644
index 0000000..584cd39
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -0,0 +1,520 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
+import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
+import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
+import {_testOnly_flushPreinstalls} from './gr-gerrit.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-js-api-interface');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-plugin-loader tests', () => {
+  let plugin;
+
+  let url;
+  let sendStub;
+  let pluginLoader;
+  let clock;
+
+  setup(() => {
+    clock = sinon.useFakeTimers();
+
+    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    pluginLoader = _testOnly_resetPluginLoader();
+    sinon.stub(document.body, 'appendChild');
+    basicFixture.instantiate();
+    url = window.location.origin;
+  });
+
+  teardown(() => {
+    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 = sinon.stub();
+    _testOnly_flushPreinstalls();
+    assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
+    delete window.Gerrit.flushPreinstalls;
+  });
+
+  test('versioning', () => {
+    const callback = sinon.spy();
+    pluginApi.install(callback, '0.0pre-alpha');
+    assert(callback.notCalled);
+  });
+
+  test('report pluginsLoaded', async () => {
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+    pluginsLoadedStub.reset();
+    window.Gerrit._loadPlugins([]);
+    await flush();
+    assert.isTrue(pluginsLoadedStub.called);
+  });
+
+  test('arePluginsLoaded', () => {
+    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
+    clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    flush();
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+  });
+
+  test('plugins installed successfully', async () => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+
+    await flush();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+  });
+
+  test('isPluginEnabled and isPluginLoaded', () => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => void 0, undefined, url);
+    });
+
+    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))
+    );
+  });
+
+  test('plugins installed mixed result, 1 fail 1 succeed', async () => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => {
+        if (url === plugins[0]) {
+          throw new Error('failed');
+        }
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    pluginLoader.loadPlugins(plugins);
+
+    await flush();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('isPluginEnabled and isPluginLoaded for mixed results', async () => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => {
+        if (url === plugins[0]) {
+          throw new Error('failed');
+        }
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    pluginLoader.loadPlugins(plugins);
+    assert.isTrue(
+        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
+    );
+
+    await 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]));
+  });
+
+  test('plugins installed all failed', async () => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => {
+        throw new Error('failed');
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    pluginLoader.loadPlugins(plugins);
+
+    await flush();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+    assert.isTrue(alertStub.calledTwice);
+  });
+
+  test('plugins installed failed becasue of wrong version', async () => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => {
+      }, url === plugins[0] ? '' : 'alpha', url);
+    });
+
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    pluginLoader.loadPlugins(plugins);
+
+    await flush();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('multiple assets for same plugin installed successfully', async () => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    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);
+
+    await flush();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+  });
+
+  suite('plugin path and url', () => {
+    let importHtmlPluginStub;
+    let loadJsPluginStub;
+    setup(() => {
+      importHtmlPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_loadHtmlPlugin').callsFake( url => {
+        importHtmlPluginStub(url);
+      });
+      loadJsPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
+        loadJsPluginStub(url);
+      });
+    });
+
+    test('invalid plugin path', () => {
+      const failToLoadStub = sinon.stub();
+      sinon.stub(pluginLoader, '_failToLoad').callsFake((...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';
+      stubBaseUrl(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 = sinon.stub();
+      sinon.stub(pluginLoader, '_loadHtmlPlugin').callsFake( url => {
+        importHtmlPluginStub(url);
+      });
+      loadJsPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
+        loadJsPluginStub(url);
+      });
+    });
+
+    teardown(() => {
+      window.ASSETS_PATH = '';
+    });
+
+    test('Should try load plugins from assets path instead', () => {
+      pluginLoader.loadPlugins([
+        'foo/bar.js',
+        'foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+    });
+
+    test('Should honor original path if exists', () => {
+      pluginLoader.loadPlugins([
+        'http://e.com/foo/bar.html',
+        'http://e.com/foo/bar.js',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
+    });
+
+    test('Should try replace current host with assetsPath', () => {
+      const host = window.location.origin;
+      pluginLoader.loadPlugins([
+        `${host}/foo/bar.html`,
+        `${host}/foo/bar.js`,
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+    });
+  });
+
+  test('adds js plugins will call the body', () => {
+    pluginLoader.loadPlugins([
+      'http://e.com/foo/bar.js',
+      'http://e.com/bar/foo.js',
+    ]);
+    assert.isTrue(document.body.appendChild.calledTwice);
+  });
+
+  test('can call awaitPluginsLoaded multiple times', async () => {
+    const plugins = [
+      'http://e.com/foo/bar.js',
+      'http://e.com/bar/foo.js',
+    ];
+
+    let installed = false;
+    function pluginCallback(url) {
+      if (url === plugins[1]) {
+        installed = true;
+      }
+    }
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => pluginCallback(url), undefined, url);
+    });
+
+    pluginLoader.loadPlugins(plugins);
+
+    await pluginLoader.awaitPluginsLoaded();
+    assert.isTrue(installed);
+    await pluginLoader.awaitPluginsLoaded();
+  });
+
+  suite('preloaded plugins', () => {
+    teardown(() => {
+      window.Gerrit._preloadedPlugins = null;
+    });
+    test('skips preloaded plugins when load plugins', () => {
+      const importHtmlPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_importHtmlPlugin').callsFake( url => {
+        importHtmlPluginStub(url);
+      });
+      const loadJsPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+        loadJsPluginStub(url);
+      });
+
+      window.Gerrit._preloadedPlugins = {
+        foo: () => void 0,
+        bar: () => void 0,
+      };
+
+      pluginLoader.loadPlugins([
+        'http://e.com/plugins/foo.js',
+        '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')
+      );
+    });
+
+    test('preloaded plugins are installed', () => {
+      const installStub = sinon.stub();
+      window.Gerrit._preloadedPlugins = {foo: installStub};
+      pluginLoader.installPreloadedPlugins();
+      assert.isTrue(installStub.called);
+      const pluginApi = installStub.lastCall.args[0];
+      assert.strictEqual(pluginApi.getPluginName(), 'foo');
+    });
+
+    test('installing preloaded plugin', () => {
+      let plugin;
+      pluginApi.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
+      assert.strictEqual(plugin.getPluginName(), 'foo');
+      assert.strictEqual(plugin.url('/some/thing.html'),
+          `${window.location.origin}/plugins/foo/some/thing.html`);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
deleted file mode 100644
index d84cd834..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
+++ /dev/null
@@ -1,147 +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.
- */
-
-let restApi;
-
-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 response;
-  });
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
new file mode 100644
index 0000000..00b2963
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -0,0 +1,182 @@
+/**
+ * @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 {
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {HttpMethod} from '../../../constants/constants';
+import {RequestPayload} from '../../../types/common';
+
+let restApi: RestApiService | null = null;
+
+export function _testOnlyResetRestApi() {
+  restApi = null;
+}
+
+function getRestApi(): RestApiService {
+  if (!restApi) {
+    restApi = (document.createElement(
+      'gr-rest-api-interface'
+    ) as unknown) as RestApiService;
+  }
+  return restApi;
+}
+
+export class GrPluginRestApi {
+  constructor(private readonly prefix = '') {}
+
+  getLoggedIn() {
+    return getRestApi().getLoggedIn();
+  }
+
+  getVersion() {
+    return getRestApi().getVersion();
+  }
+
+  getConfig() {
+    return getRestApi().getConfig();
+  }
+
+  invalidateReposCache() {
+    getRestApi().invalidateReposCache();
+  }
+
+  getAccount() {
+    return getRestApi().getAccount();
+  }
+
+  getAccountCapabilities(capabilities: string[]) {
+    return getRestApi().getAccountCapabilities(capabilities);
+  }
+
+  getRepos(filter: string, reposPerPage: number, offset?: number) {
+    return getRestApi().getRepos(filter, reposPerPage, offset);
+  }
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: undefined,
+    contentType?: string
+  ): Promise<Response>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  /**
+   * Fetch and return native browser REST API Response.
+   */
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void> {
+    return getRestApi().send(
+      method,
+      this.prefix + url,
+      payload,
+      errFn,
+      contentType
+    );
+  }
+
+  /**
+   * Fetch and parse REST API response, if request succeeds.
+   */
+  send(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ) {
+    return this.fetch(method, url, payload, errFn, contentType).then(
+      response => {
+        if (!response) {
+          // TODO(TS): Fix method definition
+          // If errFn exists and doesn't throw an exception, the fetch method
+          // returns empty response
+          throw new Error('errFn must throw an exception');
+        }
+        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);
+        }
+      }
+    );
+  }
+
+  get(url: string) {
+    return this.send(HttpMethod.GET, url);
+  }
+
+  post(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ) {
+    return this.send(HttpMethod.POST, url, payload, errFn, contentType);
+  }
+
+  put(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ) {
+    return this.send(HttpMethod.PUT, url, payload, errFn, contentType);
+  }
+
+  delete(url: string) {
+    return this.fetch(HttpMethod.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 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
deleted file mode 100644
index fcc3b669..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
+++ /dev/null
@@ -1,160 +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">
-<meta charset="utf-8">
-<title>gr-plugin-rest-api</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 './gr-js-api-interface.js';
-import {GrPluginRestApi} from './gr-plugin-rest-api.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-rest-api tests', () => {
-  let instance;
-  let sandbox;
-  let getResponseObjectStub;
-  let sendStub;
-  let restApiStub;
-
-  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();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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-plugin-rest-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
new file mode 100644
index 0000000..53aaa1e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
@@ -0,0 +1,145 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {GrPluginRestApi} from './gr-plugin-rest-api.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-plugin-rest-api tests', () => {
+  let instance;
+
+  let getResponseObjectStub;
+  let sendStub;
+  let restApiStub;
+
+  setup(() => {
+    getResponseObjectStub = sinon.stub().returns(Promise.resolve());
+    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
+    restApiStub = {
+      getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
+      getResponseObject: getResponseObjectStub,
+      send: sendStub,
+      getLoggedIn: sinon.stub(),
+      getVersion: sinon.stub(),
+      getConfig: sinon.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('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');
+    });
+  });
+});
+
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
deleted file mode 100644
index 9d79462..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ /dev/null
@@ -1,399 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {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 PANEL_ENDPOINTS_MAPPING = {
-    CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
-    CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
-  };
-
-  /**
-   * Plugin-provided custom components can affect content in extension
-   * points using one of following methods:
-   * - DECORATE: custom component is set with `content` attribute and may
-   *   decorate (e.g. style) DOM element.
-   * - REPLACE: contents of extension point are replaced with the custom
-   *   component.
-   * - STYLE: custom component is a shared styles module that is inserted
-   *   into the extension point.
-   */
-  const EndpointType = {
-    DECORATE: 'decorate',
-    REPLACE: 'replace',
-    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 this;
-    }
-    this.deprecated = {
-      _loadedGwt: deprecatedAPI._loadedGwt.bind(this),
-      install: deprecatedAPI.install.bind(this),
-      onAction: deprecatedAPI.onAction.bind(this),
-      panel: deprecatedAPI.panel.bind(this),
-      popup: deprecatedAPI.popup.bind(this),
-      screen: deprecatedAPI.screen.bind(this),
-      settingsScreen: deprecatedAPI.settingsScreen.bind(this),
-    };
-
-    this._url = new URL(opt_url);
-    this._name = getPluginNameFromUrl(this._url);
-  }
-
-  Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
-
-  Plugin.prototype._name = '';
-
-  Plugin.prototype.getPluginName = function() {
-    return this._name;
-  };
-
-  Plugin.prototype.registerStyleModule = function(endpoint, moduleName) {
-    pluginEndpoints.registerModule(
-        this, {endpoint, type: EndpointType.STYLE, moduleName});
-  };
-
-  /**
-   * Registers an endpoint for the plugin.
-   */
-  Plugin.prototype.registerCustomComponent = function(
-      endpointName, opt_moduleName, opt_options) {
-    return this._registerCustomComponent(endpointName, opt_moduleName,
-        opt_options);
-  };
-
-  /**
-   * Registers a dynamic endpoint for the plugin.
-   *
-   * Dynamic plugins are registered by specific prefix, such as
-   * 'change-list-header'.
-   */
-  Plugin.prototype.registerDynamicCustomComponent = function(
-      endpointName, opt_moduleName, opt_options) {
-    const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
-    return this._registerCustomComponent(fullEndpointName, opt_moduleName,
-        opt_options, endpointName);
-  };
-
-  Plugin.prototype._registerCustomComponent = function(
-      endpoint, opt_moduleName, opt_options, dynamicEndpoint) {
-    const type = opt_options && opt_options.replace ?
-      EndpointType.REPLACE : EndpointType.DECORATE;
-    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();
-  };
-
-  /**
-   * Returns instance of DOM hook API for endpoint. Creates a placeholder
-   * element for the first call.
-   */
-  Plugin.prototype.hook = function(endpointName, opt_options) {
-    return this.registerCustomComponent(endpointName, undefined, opt_options);
-  };
-
-  Plugin.prototype.getServerInfo = function() {
-    return document.createElement('gr-rest-api-interface').getConfig();
-  };
-
-  Plugin.prototype.on = function(eventName, callback) {
-    Plugin._sharedAPIElement.addEventCallback(eventName, callback);
-  };
-
-  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 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;
-    }
-  };
-
-  Plugin.prototype.screenUrl = function(opt_screenName) {
-    const origin = location.origin;
-    const base = BaseUrlBehavior.getBaseUrl();
-    const tokenPart = opt_screenName ? '/' + opt_screenName : '';
-    return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
-  };
-
-  Plugin.prototype._send = function(method, url, opt_callback, opt_payload) {
-    return send(method, this.url(url), opt_callback, opt_payload);
-  };
-
-  Plugin.prototype.get = function(url, opt_callback) {
-    console.warn('.get() is deprecated! Use .restApi().get()');
-    return this._send('GET', url, opt_callback);
-  };
-
-  Plugin.prototype.post = function(url, payload, opt_callback) {
-    console.warn('.post() is deprecated! Use .restApi().post()');
-    return this._send('POST', url, opt_callback, payload);
-  };
-
-  Plugin.prototype.put = function(url, payload, opt_callback) {
-    console.warn('.put() is deprecated! Use .restApi().put()');
-    return this._send('PUT', url, opt_callback, payload);
-  };
-
-  Plugin.prototype.delete = function(url, opt_callback) {
-    return deprecatedDelete(this.url(url), opt_callback);
-  };
-
-  Plugin.prototype.annotationApi = function() {
-    return new GrAnnotationActionsInterface(this);
-  };
-
-  Plugin.prototype.changeActions = function() {
-    return new GrChangeActionsInterface(this,
-        Plugin._sharedAPIElement.getElement(
-            Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
-  };
-
-  Plugin.prototype.changeReply = function() {
-    return new GrChangeReplyInterface(this);
-  };
-
-  Plugin.prototype.theme = function() {
-    return new GrThemeApi(this);
-  };
-
-  Plugin.prototype.project = function() {
-    return new GrRepoApi(this);
-  };
-
-  Plugin.prototype.changeMetadata = function() {
-    return new GrChangeMetadataApi(this);
-  };
-
-  Plugin.prototype.admin = function() {
-    return new GrAdminApi(this);
-  };
-
-  Plugin.prototype.settings = function() {
-    return new GrSettingsApi(this);
-  };
-
-  Plugin.prototype.styles = function() {
-    return new GrStylesApi();
-  };
-
-  /**
-   * To make REST requests for plugin-provided endpoints, use
-   *
-   * @example
-   * const pluginRestApi = plugin.restApi(plugin.url());
-   *
-   * @param {string=} opt_prefix url for subsequent .get(), .post() etc requests.
-   */
-  Plugin.prototype.restApi = function(opt_prefix) {
-    return new GrPluginRestApi(opt_prefix);
-  };
-
-  Plugin.prototype.attributeHelper = function(element) {
-    return new GrAttributeHelper(element);
-  };
-
-  Plugin.prototype.eventHelper = function(element) {
-    return new GrEventHelper(element);
-  };
-
-  Plugin.prototype.popup = function(moduleName) {
-    if (typeof moduleName !== 'string') {
-      console.error('.popup(element) deprecated, use .popup(moduleName)!');
-      return;
-    }
-    const api = new GrPopupInterface(this, moduleName);
-    return api.open();
-  };
-
-  Plugin.prototype.panel = function() {
-    console.error('.panel() is deprecated! ' +
-        'Use registerCustomComponent() instead.');
-  };
-
-  Plugin.prototype.settingsScreen = function() {
-    console.error('.settingsScreen() is deprecated! ' +
-        'Use .settings() instead.');
-  };
-
-  Plugin.prototype.screen = function(screenName, opt_moduleName) {
-    if (opt_moduleName && typeof opt_moduleName !== 'string') {
-      console.error('.screen(pattern, callback) deprecated, use ' +
-          '.screen(screenName, opt_moduleName)!');
-      return;
-    }
-    return this.registerCustomComponent(
-        this._getScreenName(screenName),
-        opt_moduleName);
-  };
-
-  Plugin.prototype._getScreenName = function(screenName) {
-    return `${this.getPluginName()}-screen-${screenName}`;
-  };
-
-  const deprecatedAPI = {
-    _loadedGwt: ()=> {},
-
-    install() {
-      console.log('Installing deprecated APIs is deprecated!');
-      for (const method in this.deprecated) {
-        if (method === 'install') continue;
-        this[method] = this.deprecated[method];
-      }
-    },
-
-    popup(el) {
-      console.warn('plugin.deprecated.popup() is deprecated, ' +
-          'use plugin.popup() insted!');
-      if (!el) {
-        throw new Error('Popup contents not found');
-      }
-      const api = new GrPopupInterface(this);
-      api.open().then(api => api._getElement().appendChild(el));
-      return api;
-    },
-
-    onAction(type, action, callback) {
-      console.warn('plugin.deprecated.onAction() is deprecated,' +
-          ' use plugin.changeActions() instead!');
-      if (type !== 'change' && type !== 'revision') {
-        console.warn(`${type} actions are not supported.`);
-        return;
-      }
-      this.on('showchange', (change, revision) => {
-        const details = this.changeActions().getActionDetails(action);
-        if (!details) {
-          console.warn(
-              `${this.getPluginName()} onAction error: ${action} not found!`);
-          return;
-        }
-        this.changeActions().addTapListener(details.__key, () => {
-          callback(new GrPluginActionContext(this, details, change, revision));
-        });
-      });
-    },
-
-    screen(pattern, callback) {
-      console.warn('plugin.deprecated.screen is deprecated,' +
-          ' use plugin.screen instead!');
-      if (pattern instanceof RegExp) {
-        console.error('deprecated.screen() does not support RegExp. ' +
-            'Please use strings for patterns.');
-        return;
-      }
-      this.hook(this._getScreenName(pattern))
-          .onAttached(el => {
-            el.style.display = 'none';
-            callback({
-              body: el,
-              token: el.token,
-              onUnload: () => {},
-              setTitle: () => {},
-              setWindowTitle: () => {},
-              show: () => {
-                el.style.display = 'initial';
-              },
-            });
-          });
-    },
-
-    settingsScreen(path, menu, callback) {
-      console.warn('.settingsScreen() is deprecated! Use .settings() instead.');
-      const hook = this.settings()
-          .title(menu)
-          .token(path)
-          .module('div')
-          .build();
-      hook.onAttached(el => {
-        el.style.display = 'none';
-        const body = el.querySelector('div');
-        callback({
-          body,
-          onUnload: () => {},
-          setTitle: () => {},
-          setWindowTitle: () => {},
-          show: () => {
-            el.style.display = 'initial';
-          },
-        });
-      });
-    },
-
-    panel(extensionpoint, callback) {
-      console.warn('.panel() is deprecated! ' +
-          'Use registerCustomComponent() instead.');
-      const endpoint = PANEL_ENDPOINTS_MAPPING[extensionpoint];
-      if (!endpoint) {
-        console.warn(`.panel ${extensionpoint} not supported!`);
-        return;
-      }
-      this.hook(endpoint).onAttached(el => callback({
-        body: el,
-        p: {
-          CHANGE_INFO: el.change,
-          REVISION_INFO: el.revision,
-        },
-        onUnload: () => {},
-      }));
-    },
-  };
-
-  window.Plugin = Plugin;
-})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
new file mode 100644
index 0000000..0625f67
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -0,0 +1,329 @@
+/**
+ * @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 {getBaseUrl} from '../../../utils/url-util';
+import {getSharedApiEl} from '../../../utils/dom-util';
+import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper';
+import {GrChangeActionsInterface} from './gr-change-actions-js-api';
+import {GrChangeReplyInterface} from './gr-change-reply-js-api';
+import {GrDomHooksManager} from '../../plugins/gr-dom-hooks/gr-dom-hooks';
+import {GrThemeApi} from '../../plugins/gr-theme-api/gr-theme-api';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
+import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {GrChangeMetadataApi} from '../../plugins/gr-change-metadata-api/gr-change-metadata-api';
+import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
+import {GrPluginRestApi} from './gr-plugin-rest-api';
+import {GrRepoApi} from '../../plugins/gr-repo-api/gr-repo-api';
+import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api';
+import {GrStylesApi} from '../../plugins/gr-styles-api/gr-styles-api';
+import {getPluginEndpoints} from './gr-plugin-endpoints';
+
+import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils';
+import {GrReporintJsApi} from './gr-reporting-js-api';
+import {
+  EventType,
+  HookApi,
+  PluginApi,
+  RegisterOptions,
+  TargetElement,
+} from '../../plugins/gr-plugin-types';
+import {RequestPayload} from '../../../types/common';
+import {HttpMethod} from '../../../constants/constants';
+import {JsApiService} from './gr-js-api-types';
+import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
+
+/**
+ * Plugin-provided custom components can affect content in extension
+ * points using one of following methods:
+ * - DECORATE: custom component is set with `content` attribute and may
+ *   decorate (e.g. style) DOM element.
+ * - REPLACE: contents of extension point are replaced with the custom
+ *   component.
+ * - STYLE: custom component is a shared styles module that is inserted
+ *   into the extension point.
+ */
+enum EndpointType {
+  DECORATE = 'decorate',
+  REPLACE = 'replace',
+  STYLE = 'style',
+}
+
+const PLUGIN_NAME_NOT_SET = 'NULL';
+
+export type SendCallback = (response: unknown) => void;
+
+export class Plugin implements PluginApi {
+  readonly _url?: URL;
+
+  private _domHooks: GrDomHooksManager;
+
+  private readonly _name: string = PLUGIN_NAME_NOT_SET;
+
+  // TODO(TS): Change type to GrJsApiInterface
+  private readonly sharedApiElement: JsApiService;
+
+  constructor(url?: string) {
+    this.sharedApiElement = getSharedApiEl();
+    this._domHooks = new GrDomHooksManager(this);
+
+    if (!url) {
+      console.warn(
+        'Plugin not being loaded from /plugins base path.',
+        'Unable to determine name.'
+      );
+      return this;
+    }
+
+    this._url = new URL(url);
+    this._name = getPluginNameFromUrl(this._url) ?? 'NULL';
+  }
+
+  getPluginName() {
+    return this._name;
+  }
+
+  registerStyleModule(endpoint: string, moduleName: string) {
+    getPluginEndpoints().registerModule(this, {
+      endpoint,
+      type: EndpointType.STYLE,
+      moduleName,
+    });
+  }
+
+  /**
+   * Registers an endpoint for the plugin.
+   */
+  registerCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi {
+    return this._registerCustomComponent(endpointName, moduleName, options);
+  }
+
+  /**
+   * Registers a dynamic endpoint for the plugin.
+   *
+   * Dynamic plugins are registered by specific prefix, such as
+   * 'change-list-header'.
+   */
+  registerDynamicCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi {
+    const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
+    return this._registerCustomComponent(
+      fullEndpointName,
+      moduleName,
+      options,
+      endpointName
+    );
+  }
+
+  _registerCustomComponent(
+    endpoint: string,
+    moduleName?: string,
+    options?: RegisterOptions,
+    dynamicEndpoint?: string
+  ): HookApi {
+    const type =
+      options && options.replace ? EndpointType.REPLACE : EndpointType.DECORATE;
+    const slot = (options && options.slot) || '';
+    const domHook = this._domHooks.getDomHook(endpoint, moduleName);
+    moduleName = moduleName || domHook.getModuleName();
+    getPluginEndpoints().registerModule(this, {
+      slot,
+      endpoint,
+      type,
+      moduleName,
+      domHook,
+      dynamicEndpoint,
+    });
+    return domHook;
+  }
+
+  /**
+   * Returns instance of DOM hook API for endpoint. Creates a placeholder
+   * element for the first call.
+   */
+  hook(endpointName: string, options?: RegisterOptions) {
+    return this.registerCustomComponent(endpointName, undefined, options);
+  }
+
+  getServerInfo() {
+    return document.createElement('gr-rest-api-interface').getConfig();
+  }
+
+  on(eventName: EventType, callback: (...args: any[]) => any) {
+    this.sharedApiElement.addEventCallback(eventName, callback);
+  }
+
+  url(path?: string) {
+    if (!this._url) throw new Error('plugin url not set');
+    const relPath = '/plugins/' + this._name + (path || '/');
+    const sameOriginPath = window.location.origin + `${getBaseUrl()}${relPath}`;
+    if (window.location.origin === this._url.origin) {
+      // Plugin loaded from the same origin as gr-app, getBaseUrl in effect.
+      return sameOriginPath;
+    } else if (this._url.protocol === PRELOADED_PROTOCOL) {
+      // Plugin is preloaded, load plugin with ASSETS_PATH or location.origin
+      return window.ASSETS_PATH
+        ? `${window.ASSETS_PATH}${relPath}`
+        : sameOriginPath;
+    } else {
+      // Plugin loaded from assets bundle, expect assets placed along with it.
+      return this._url.href.split('/plugins/' + this._name)[0] + relPath;
+    }
+  }
+
+  screenUrl(screenName?: string) {
+    const origin = location.origin;
+    const base = getBaseUrl();
+    const tokenPart = screenName ? '/' + screenName : '';
+    return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
+  }
+
+  _send(
+    method: HttpMethod,
+    url: string,
+    callback?: SendCallback,
+    payload?: RequestPayload
+  ) {
+    return send(method, this.url(url), callback, payload);
+  }
+
+  get(url: string, callback?: SendCallback) {
+    console.warn('.get() is deprecated! Use .restApi().get()');
+    return this._send(HttpMethod.GET, url, callback);
+  }
+
+  post(url: string, payload: RequestPayload, callback?: SendCallback) {
+    console.warn('.post() is deprecated! Use .restApi().post()');
+    return this._send(HttpMethod.POST, url, callback, payload);
+  }
+
+  put(url: string, payload: RequestPayload, callback?: SendCallback) {
+    console.warn('.put() is deprecated! Use .restApi().put()');
+    return this._send(HttpMethod.PUT, url, callback, payload);
+  }
+
+  delete(url: string, callback?: SendCallback) {
+    console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
+    return this.restApi()
+      .delete(this.url(url))
+      .then(res => {
+        if (callback) callback(res);
+        return res;
+      });
+  }
+
+  annotationApi() {
+    return new GrAnnotationActionsInterface(this);
+  }
+
+  changeActions() {
+    return new GrChangeActionsInterface(
+      this,
+      (this.sharedApiElement.getElement(
+        TargetElement.CHANGE_ACTIONS
+      ) as unknown) as GrChangeActions
+    );
+  }
+
+  changeReply() {
+    return new GrChangeReplyInterface(this, this.sharedApiElement);
+  }
+
+  reporting() {
+    return new GrReporintJsApi(this);
+  }
+
+  theme() {
+    return new GrThemeApi(this);
+  }
+
+  project() {
+    return new GrRepoApi(this);
+  }
+
+  changeMetadata() {
+    return new GrChangeMetadataApi(this);
+  }
+
+  admin() {
+    return new GrAdminApi(this);
+  }
+
+  settings() {
+    return new GrSettingsApi(this);
+  }
+
+  styles() {
+    return new GrStylesApi();
+  }
+
+  /**
+   * To make REST requests for plugin-provided endpoints, use
+   *
+   * @example
+   * const pluginRestApi = plugin.restApi(plugin.url());
+   * @param prefix url for subsequent .get(), .post() etc requests.
+   */
+  restApi(prefix?: string) {
+    return new GrPluginRestApi(prefix);
+  }
+
+  attributeHelper(element: HTMLElement) {
+    return new GrAttributeHelper(element);
+  }
+
+  eventHelper(element: HTMLElement) {
+    return new GrEventHelper(element);
+  }
+
+  popup(): Promise<GrPopupInterface>;
+
+  popup(moduleName: string): Promise<GrPopupInterface>;
+
+  popup(moduleName?: string): Promise<GrPopupInterface | null> {
+    if (moduleName !== undefined && typeof moduleName !== 'string') {
+      console.error('.popup(element) deprecated, use .popup(moduleName)!');
+      return Promise.resolve(null);
+    }
+    return new GrPopupInterface(this, moduleName).open();
+  }
+
+  screen(screenName: string, moduleName?: string) {
+    if (moduleName && typeof moduleName !== 'string') {
+      console.error(
+        '.screen(pattern, callback) deprecated, use ' +
+          '.screen(screenName, moduleName)!'
+      );
+      return;
+    }
+    return this.registerCustomComponent(
+      this._getScreenName(screenName),
+      moduleName
+    );
+  }
+
+  _getScreenName(screenName: string) {
+    return `${this.getPluginName()}-screen-${screenName}`;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
new file mode 100644
index 0000000..0bf6676
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -0,0 +1,47 @@
+/**
+ * @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';
+import {EventDetails} from '../../../services/gr-reporting/gr-reporting';
+
+// TODO(TS): remove once Plugin api converted to ts
+interface PluginApi {
+  getPluginName(): string;
+}
+
+/**
+ * Defines all methods that will be exported to plugin from reporting service.
+ */
+export class GrReporintJsApi {
+  private readonly reporting = appContext.reportingService;
+
+  constructor(private readonly plugin: PluginApi) {}
+
+  reportInteraction(eventName: string, details?: EventDetails) {
+    return this.reporting.reportInteraction(
+      `${this.plugin.getPluginName()}-${eventName}`,
+      details
+    );
+  }
+
+  reportLifeCycle(eventName: string, details?: EventDetails) {
+    return this.reporting.reportLifeCycle(
+      `${this.plugin.getPluginName()}-${eventName}`,
+      details
+    );
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
new file mode 100644
index 0000000..1229641
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.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 '../../../test/common-test-setup-karma.js';
+import '../../change/gr-reply-dialog/gr-reply-dialog.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {appContext} from '../../../services/app-context.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-reporting-js-api tests', () => {
+  let reporting;
+  let plugin;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve(null); },
+    });
+  });
+
+  suite('early init', () => {
+    setup(() => {
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      reporting = plugin.reporting();
+    });
+
+    teardown(() => {
+      reporting = null;
+    });
+
+    test('redirect reportInteraction call to reportingService', () => {
+      sinon.spy(appContext.reportingService, 'reportInteraction');
+      reporting.reportInteraction('test', {});
+      assert.isTrue(appContext.reportingService.reportInteraction.called);
+      assert.equal(
+          appContext.reportingService.reportInteraction.lastCall.args[0],
+          'testplugin-test'
+      );
+      assert.deepEqual(
+          appContext.reportingService.reportInteraction.lastCall.args[1],
+          {}
+      );
+    });
+
+    test('redirect reportLifeCycle call to reportingService', () => {
+      sinon.spy(appContext.reportingService, 'reportLifeCycle');
+      reporting.reportLifeCycle('test', {});
+      assert.isTrue(appContext.reportingService.reportLifeCycle.called);
+      assert.equal(
+          appContext.reportingService.reportLifeCycle.lastCall.args[0],
+          'testplugin-test'
+      );
+      assert.deepEqual(
+          appContext.reportingService.reportLifeCycle.lastCall.args[1],
+          {}
+      );
+    });
+  });
+});
\ No newline at end of file
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
deleted file mode 100644
index 22bdce1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ /dev/null
@@ -1,189 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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);
-        }
-      }
-    }
-    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.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
new file mode 100644
index 0000000..1dac371
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -0,0 +1,267 @@
+/**
+ * @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.
+ */
+import '../../../styles/gr-voting-styles';
+import '../../../styles/shared-styles';
+import '../gr-account-label/gr-account-label';
+import '../gr-account-link/gr-account-link';
+import '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import '../gr-label/gr-label';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-label-info_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  AccountInfo,
+  LabelInfo,
+  ApprovalInfo,
+  AccountId,
+  isQuickLabelInfo,
+  isDetailedLabelInfo,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrButton} from '../gr-button/gr-button';
+import {getVotingRangeOrDefault} from '../../../utils/label-util';
+
+export interface GrLabelInfo {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-label-info': GrLabelInfo;
+  }
+}
+
+enum LabelClassName {
+  NEGATIVE = 'negative',
+  POSITIVE = 'positive',
+  MIN = 'min',
+  MAX = 'max',
+}
+
+interface FormattedLabel {
+  className?: LabelClassName;
+  account: ApprovalInfo;
+  value: string;
+}
+
+@customElement('gr-label-info')
+export class GrLabelInfo extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  labelInfo?: LabelInfo;
+
+  @property({type: String})
+  label = '';
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Boolean})
+  mutable = false;
+
+  // TODO(TS): not used, remove later
+  _xhrPromise?: Promise<void>;
+
+  /**
+   * This method also listens on change.labels.*,
+   * to trigger computation when a label is removed from the change.
+   */
+  _mapLabelInfo(labelInfo?: LabelInfo, account?: AccountInfo) {
+    const result: FormattedLabel[] = [];
+    if (!labelInfo) {
+      return result;
+    }
+    if (!isDetailedLabelInfo(labelInfo)) {
+      if (
+        isQuickLabelInfo(labelInfo) &&
+        (labelInfo.rejected || labelInfo.approved)
+      ) {
+        const ok = labelInfo.approved || !labelInfo.rejected;
+        return [
+          {
+            value: ok ? '👍️' : '👎️',
+            className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
+            account: ok ? labelInfo.approved : labelInfo.rejected,
+          },
+        ];
+      }
+      return result;
+    }
+
+    // Sort votes by positivity.
+    // TODO(TS): maybe mark value as required if always present
+    const votes = (labelInfo.all || []).sort(
+      (a, b) => (a.value || 0) - (b.value || 0)
+    );
+    const votingRange = getVotingRangeOrDefault(labelInfo);
+    for (const label of votes) {
+      if (
+        label.value &&
+        (!isQuickLabelInfo(labelInfo) ||
+          label.value !== labelInfo.default_value)
+      ) {
+        let labelClassName;
+        let labelValPrefix = '';
+        if (label.value > 0) {
+          labelValPrefix = '+';
+          if (label.value === votingRange.max) {
+            labelClassName = LabelClassName.MAX;
+          } else {
+            labelClassName = LabelClassName.POSITIVE;
+          }
+        } else if (label.value < 0) {
+          if (label.value === votingRange.min) {
+            labelClassName = LabelClassName.MIN;
+          } else {
+            labelClassName = 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 reviewer An object describing the reviewer that left the
+   *     vote.
+   */
+  _computeDeleteClass(
+    reviewer: ApprovalInfo,
+    mutable: boolean,
+    change: ChangeInfo
+  ) {
+    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, suppressing annotations.
+   */
+  _onDeleteVote(e: MouseEvent) {
+    if (!this.change) return;
+
+    e.preventDefault();
+    let target = (dom(e) as EventApi).rootTarget as GrButton;
+    while (!target.classList.contains('deleteBtn')) {
+      if (!target.parentElement) {
+        return;
+      }
+      target = target.parentElement as GrButton;
+    }
+
+    target.disabled = true;
+    const accountID = Number(
+      `${target.getAttribute('data-account-id')}`
+    ) as AccountId;
+    this._xhrPromise = this.$.restAPI
+      .deleteVote(this.change._number, accountID, this.label)
+      .then(response => {
+        target.disabled = false;
+        if (!response.ok) {
+          return;
+        }
+        if (this.change) {
+          GerritNav.navigateToChange(this.change);
+        }
+      })
+      .catch(err => {
+        console.warn(err);
+        target.disabled = false;
+        return;
+      });
+  }
+
+  _computeValueTooltip(labelInfo: LabelInfo, score: string) {
+    if (
+      !labelInfo ||
+      !isDetailedLabelInfo(labelInfo) ||
+      !labelInfo.values[score]
+    ) {
+      return '';
+    }
+    return labelInfo.values[score];
+  }
+
+  /**
+   * This method also listens change.labels.* in
+   * order to trigger computation when a label is removed from the change.
+   */
+  _computeShowPlaceholder(labelInfo?: LabelInfo) {
+    if (!labelInfo) {
+      return '';
+    }
+    if (
+      !isDetailedLabelInfo(labelInfo) &&
+      isQuickLabelInfo(labelInfo) &&
+      (labelInfo.rejected || labelInfo.approved)
+    ) {
+      return 'hidden';
+    }
+
+    if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
+      for (const label of labelInfo.all) {
+        if (
+          label.value &&
+          (!isQuickLabelInfo(labelInfo) ||
+            label.value !== labelInfo.default_value)
+        ) {
+          return 'hidden';
+        }
+      }
+    }
+    return '';
+  }
+}
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
deleted file mode 100644
index 2a86669..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
new file mode 100644
index 0000000..3955cd4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <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);
+    }
+    .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-link {
+      --account-max-length: 120px;
+      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-link account="[[mappedLabel.account]]"></gr-account-link>
+        </td>
+        <td>
+          <gr-button
+            link=""
+            aria-label="Remove vote"
+            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
deleted file mode 100644
index d7ccc45..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
+++ /dev/null
@@ -1,249 +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.
--->
-
-<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="/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-label-info></gr-label-info>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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();
-    });
-
-    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));
-    });
-
-    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.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'));
-      });
-    });
-  });
-
-  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-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
new file mode 100644
index 0000000..3a2cc39
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -0,0 +1,237 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-label-info.js';
+import {isHidden} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-label-info');
+
+suite('gr-label-info tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    // Needed to trigger computed bindings.
+    element.account = {};
+    element.change = {labels: {}};
+  });
+
+  suite('remove reviewer votes', () => {
+    setup(() => {
+      sinon.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';
+
+      flush();
+    });
+
+    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));
+    });
+
+    test('deletes votes', () => {
+      const deleteResponse = Promise.resolve({ok: true});
+      const deleteStub = sinon.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.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'));
+      });
+    });
+  });
+
+  suite('label color and order', () => {
+    test('valueless label rejected', () => {
+      element.labelInfo = {rejected: {name: 'someone'}};
+      flush();
+      const labels = element.root.querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('negative'));
+    });
+
+    test('valueless label approved', () => {
+      element.labelInfo = {approved: {name: 'someone'}};
+      flush();
+      const labels = 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',
+        },
+      };
+      flush();
+      const labels = 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',
+        },
+      };
+      flush();
+      const labels = 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',
+        },
+      };
+      flush();
+      const labels = 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',
+        },
+      };
+      flush();
+      const chips =
+          element.root.querySelectorAll('gr-account-link');
+      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-existent 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', () => {
+    const values = {
+      '0': 'No score',
+      '+1': 'good',
+      '+2': 'excellent',
+      '-1': 'bad',
+      '-2': 'terrible',
+    };
+    element.labelInfo = {};
+    assert.isFalse(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {all: [], values};
+    assert.isFalse(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {all: [{value: 1}], values};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {rejected: []};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {values: [], rejected: [], all: [{value: 1}, values]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {approved: []};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {values: [], approved: [], all: [{value: 1}, values]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
deleted file mode 100644
index 014e85e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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 {htmlTemplate} from './gr-label_html.js';
-import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
-
-/**
- * @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.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
new file mode 100644
index 0000000..46b10cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
@@ -0,0 +1,44 @@
+/**
+ * @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.
+ */
+
+/**
+ * @fileoverview Consider removing this element as
+ * its functionality seems to be duplicated with gr-tooltip and only
+ * used in gr-label-info.
+ */
+
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement} from '@polymer/decorators';
+import {htmlTemplate} from './gr-label_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-label': GrLabel;
+  }
+}
+
+@customElement('gr-label')
+export class GrLabel extends TooltipMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+}
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
deleted file mode 100644
index c4310fc..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
new file mode 100644
index 0000000..94196df
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
@@ -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';
+
+export const htmlTemplate = html` <slot></slot> `;
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
deleted file mode 100644
index f585347..0000000
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @extends Polymer.Element */
-class GrLabeledAutocomplete extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  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.
-       *
-       * @type {function(string): Promise<?>}
-       */
-      query: {
-        type: Function,
-        value() {
-          return function() {
-            return Promise.resolve([]);
-          };
-        },
-      },
-
-      text: {
-        type: String,
-        value: '',
-        notify: true,
-      },
-      label: String,
-      placeholder: String,
-      disabled: Boolean,
-
-      _autocompleteThreshold: {
-        type: Number,
-        value: 0,
-        readOnly: true,
-      },
-    };
-  }
-
-  _handleTriggerClick(e) {
-    // Stop propagation here so we don't confuse gr-autocomplete, which
-    // listens for taps on body to try to determine when it's blurred.
-    e.stopPropagation();
-    this.$.autocomplete.focus();
-  }
-
-  setText(text) {
-    this.$.autocomplete.setText(text);
-  }
-
-  clear() {
-    this.setText('');
-  }
-}
-
-customElements.define(GrLabeledAutocomplete.is, GrLabeledAutocomplete);
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
new file mode 100644
index 0000000..4240c77
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
@@ -0,0 +1,83 @@
+/**
+ * @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.
+ */
+import '../gr-autocomplete/gr-autocomplete';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-labeled-autocomplete_html';
+import {customElement, property} from '@polymer/decorators';
+import {
+  GrAutocomplete,
+  AutocompleteQuery,
+} from '../gr-autocomplete/gr-autocomplete';
+
+export interface GrLabeledAutocomplete {
+  $: {
+    autocomplete: GrAutocomplete;
+  };
+}
+@customElement('gr-labeled-autocomplete')
+export class GrLabeledAutocomplete extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when a value is chosen.
+   *
+   * @event commit
+   */
+
+  @property({type: Object})
+  query: AutocompleteQuery = () => Promise.resolve([]);
+
+  @property({type: String, notify: true})
+  text = '';
+
+  @property({type: String})
+  label?: string;
+
+  @property({type: String})
+  placeholder?: string;
+
+  @property({type: Boolean})
+  disabled?: boolean;
+
+  _handleTriggerClick(e: Event) {
+    // 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: string) {
+    this.$.autocomplete.setText(text);
+  }
+
+  clear() {
+    this.setText('');
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-labeled-autocomplete': 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
deleted file mode 100644
index 615a525..0000000
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
new file mode 100644
index 0000000..934ab84
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
@@ -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';
+
+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="0"
+        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
deleted file mode 100644
index 99a038e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
+++ /dev/null
@@ -1,62 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-<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="/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-labeled-autocomplete></gr-labeled-autocomplete>
-  </template>
-</test-fixture>
-
-<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'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js
new file mode 100644
index 0000000..3e904a2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-labeled-autocomplete.js';
+
+const basicFixture = fixtureFromElement('gr-labeled-autocomplete');
+
+suite('gr-labeled-autocomplete tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('tapping trigger focuses autocomplete', () => {
+    const e = {stopPropagation: () => undefined};
+    sinon.stub(e, 'stopPropagation');
+    sinon.stub(element.$.autocomplete, 'focus');
+    element._handleTriggerClick(e);
+    assert.isTrue(e.stopPropagation.calledOnce);
+    assert.isTrue(element.$.autocomplete.focus.calledOnce);
+  });
+
+  test('setText', () => {
+    sinon.stub(element.$.autocomplete, 'setText');
+    element.setText('foo-bar');
+    assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
+  });
+});
+
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
deleted file mode 100644
index 9bd8a11..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
+++ /dev/null
@@ -1,176 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
-const DARK_THEME_PATH = 'styles/themes/dark-theme.html';
-
-/** @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,
-
-        // NOTE: intended singleton.
-        value: {
-          configured: false,
-          loading: false,
-          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) => {
-      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;
-      }
-
-      script.setAttribute('src', src);
-      script.onload = resolve;
-      script.onerror = reject;
-      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.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
new file mode 100644
index 0000000..ad97d02
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
@@ -0,0 +1,160 @@
+/**
+ * @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 '../gr-js-api-interface/gr-js-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-lib-loader_html';
+import {EventType} from '../../plugins/gr-plugin-types';
+import {customElement, property} from '@polymer/decorators';
+import {JsApiService} from '../gr-js-api-interface/gr-js-api-types';
+import {HighlightJS} from '../../../types/types';
+
+// preloaded in PolyGerritIndexHtml.soy
+const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
+
+type HljsCallback = (value?: HighlightJS) => void;
+
+interface HljsState {
+  configured: boolean;
+  loading: boolean;
+  callbacks: HljsCallback[];
+}
+
+export interface GrLibLoader {
+  $: {
+    jsAPI: JsApiService & Element;
+  };
+}
+@customElement('gr-lib-loader')
+export class GrLibLoader extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  // NOTE: intended singleton.
+  @property({type: Object})
+  _hljsState: HljsState = {
+    configured: false,
+    loading: false,
+    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.
+   */
+  getHLJS(): Promise<HighlightJS | undefined> {
+    return new Promise<HighlightJS | undefined>((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())
+          .catch(reject);
+      }
+
+      this._hljsState.callbacks.push(resolve);
+    });
+  }
+
+  /**
+   * Execute callbacks awaiting the HLJS lib load.
+   */
+  _onHLJSLibLoaded() {
+    const lib = this._getHighlightLib();
+    this._hljsState.loading = false;
+    this.$.jsAPI.handleEvent(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.
+   */
+  _getHighlightLib(): HighlightJS | undefined {
+    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.
+   */
+  _getLibRoot() {
+    if (window.STATIC_RESOURCE_PATH) {
+      return window.STATIC_RESOURCE_PATH + '/';
+    }
+    return '/';
+  }
+
+  /**
+   * Load and execute a JS file from the lib root.
+   *
+   * @param src The path to the JS file without the lib root.
+   * @return a promise that resolves when the script's onload
+   * executes.
+   */
+  _loadScript(src: string | null) {
+    return new Promise((resolve, reject) => {
+      const script = document.createElement('script');
+
+      if (!src) {
+        reject(new Error('Unable to load blank script url.'));
+        return;
+      }
+
+      script.setAttribute('src', src);
+      script.onload = resolve;
+      script.onerror = reject;
+      document.head.appendChild(script);
+    });
+  }
+
+  _getHLJSUrl() {
+    const root = this._getLibRoot();
+    if (!root) {
+      return null;
+    }
+    return root + HLJS_PATH;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-lib-loader': 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
deleted file mode 100644
index 204aa87..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts
new file mode 100644
index 0000000..f34f99e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts
@@ -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';
+
+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
deleted file mode 100644
index f2e5e3d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
+++ /dev/null
@@ -1,148 +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">
-<meta charset="utf-8">
-<title>gr-lib-loader</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-lib-loader></gr-lib-loader>
-  </template>
-</test-fixture>
-
-<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(() => {
-      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');
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
new file mode 100644
index 0000000..1ce175f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-lib-loader.js';
+
+const basicFixture = fixtureFromElement('gr-lib-loader');
+
+suite('gr-lib-loader tests', () => {
+  let element;
+  let resolveLoad;
+  let loadStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    loadStub = sinon.stub(element, '_loadScript').callsFake(() =>
+      new Promise(resolve => resolveLoad = resolve)
+    );
+
+    // Assert preconditions:
+    assert.isFalse(element._hljsState.loading);
+  });
+
+  teardown(() => {
+    if (window.hljs) {
+      delete window.hljs;
+    }
+
+    // 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', async () => {
+    sinon.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();
+    await flush();
+    // The state should be loaded and both handlers called.
+    assert.isFalse(element._hljsState.loading);
+    assert.isTrue(firstCallHandler.called);
+    assert.isTrue(secondCallHandler.called);
+  });
+
+  suite('preloaded', () => {
+    let hljsStub;
+
+    setup(() => {
+      hljsStub = {
+        configure: sinon.stub(),
+      };
+      window.hljs = hljsStub;
+    });
+
+    teardown(() => {
+      delete window.hljs;
+    });
+
+    test('returns hljs', async () => {
+      const firstCallHandler = sinon.stub();
+      element.getHLJS().then(firstCallHandler);
+      await flush();
+      assert.isTrue(firstCallHandler.called);
+      assert.isTrue(firstCallHandler.calledWith(hljsStub));
+    });
+
+    test('configures hljs', () => element.getHLJS().then(() => {
+      assert.isTrue(window.hljs.configure.calledOnce);
+    }));
+  });
+
+  suite('_getHLJSUrl', () => {
+    suite('checking _getLibRoot', () => {
+      let root;
+
+      setup(() => {
+        sinon.stub(element, '_getLibRoot').callsFake(() => 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');
+      });
+    });
+  });
+});
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
deleted file mode 100644
index b7bfbf3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
+++ /dev/null
@@ -1,111 +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.
- */
-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 {htmlTemplate} from './gr-limited-text_html.js';
-import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.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.
- *
- * @extends Polymer.Element
- */
-class GrLimitedText extends mixinBehaviors( [
-  TooltipBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  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. */
-      limit: {
-        type: Number,
-        value: null,
-      },
-
-      /** Boolean property used by TooltipBehavior. */
-      hasTooltip: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Disable the tooltip.
-       * When set to true, will not show tooltip even text is over limit
-       */
-      disableTooltip: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * The maximum number of characters to display in the tooltop.
-       */
-      tooltipLimit: {
-        type: Number,
-        value: 1024,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateTitle(text, limit, tooltipLimit)',
-    ];
-  }
-
-  /**
-   * 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');
-    }
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
new file mode 100644
index 0000000..4d65874
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
@@ -0,0 +1,93 @@
+/**
+ * @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';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-limited-text_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {customElement, observe, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-limited-text': GrLimitedText;
+  }
+}
+
+/**
+ * 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.
+ */
+@customElement('gr-limited-text')
+export class GrLimitedText extends TooltipMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /** The un-truncated text to display. */
+  @property({type: String})
+  text = '';
+
+  /** The maximum length for the text to display before truncating. */
+  @property({type: Number})
+  limit: number | null = null;
+
+  @property({type: String})
+  tooltip = '';
+
+  /** Boolean property used by TooltipMixin. */
+  @property({type: Boolean})
+  hasTooltip = false;
+
+  /** Boolean property used by TooltipMixin. */
+  @property({type: Boolean})
+  disableTooltip = false;
+
+  /**
+   * The text or limit have changed. Recompute whether a tooltip needs to be
+   * enabled.
+   */
+  @observe('text', 'tooltip', 'limit')
+  _updateTitle(text: string, tooltip: string, limit?: number) {
+    // Polymer 2: check for undefined
+    if ([text, limit, tooltip].includes(undefined)) {
+      return;
+    }
+
+    this.hasTooltip = !!tooltip || (!!limit && text.length > limit);
+    if (this.hasTooltip && !this.disableTooltip) {
+      // Combine the text and title if over-length
+      if (limit && text.length > limit) {
+        this.title = `${text}${tooltip ? ` (${tooltip})` : ''}`;
+      } else {
+        this.title = tooltip;
+      }
+    } else {
+      this.title = '';
+    }
+  }
+
+  _computeDisplayText(text: string, limit?: number) {
+    if (!!limit && !!text && text.length > limit) {
+      return text.substr(0, limit - 1) + '…';
+    }
+    return text;
+  }
+}
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
deleted file mode 100644
index 6bcce8c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html` [[_computeDisplayText(text, limit)]] `;
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts
new file mode 100644
index 0000000..b942d07
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 889b786..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
+++ /dev/null
@@ -1,103 +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">
-<meta charset="utf-8">
-<title>gr-limited-text</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-limited-text></gr-limited-text>
-  </template>
-</test-fixture>
-
-<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);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
new file mode 100644
index 0000000..3b99d6d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
@@ -0,0 +1,99 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-limited-text.js';
+
+const basicFixture = fixtureFromElement('gr-limited-text');
+
+suite('gr-limited-text tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('tooltip without title input', () => {
+    const updateSpy = sinon.spy(element, '_updateTitle');
+    element.text = 'abc 123';
+    flush();
+    assert.isTrue(updateSpy.calledOnce);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = 10;
+    flush();
+    assert.isTrue(updateSpy.calledTwice);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = 3;
+    flush();
+    assert.equal(updateSpy.callCount, 3);
+    assert.equal(element.getAttribute('title'), 'abc 123');
+    assert.equal(element.title, 'abc 123');
+    assert.isTrue(element.hasTooltip);
+
+    element.limit = 100;
+    flush();
+    assert.equal(updateSpy.callCount, 4);
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = null;
+    flush();
+    assert.equal(updateSpy.callCount, 5);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+  });
+
+  test('with tooltip input', () => {
+    const updateSpy = sinon.spy(element, '_updateTitle');
+    element.tooltip = 'abc 123';
+    flush();
+    assert.isTrue(updateSpy.calledOnce);
+    assert.isTrue(element.hasTooltip);
+    assert.equal(element.getAttribute('title'), 'abc 123');
+    assert.equal(element.title, 'abc 123');
+
+    element.text = 'abc';
+    flush();
+    assert.equal(element.getAttribute('title'), 'abc 123');
+    assert.isTrue(element.hasTooltip);
+
+    element.text = 'abcdef';
+    element.limit = 3;
+    flush();
+    assert.equal(element.getAttribute('title'), 'abcdef (abc 123)');
+    assert.isTrue(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', () => {
+    sinon.spy(element, '_updateTitle');
+    element.text = 'abcdefghijklmn';
+    element.disableTooltip = true;
+    element.limit = 10;
+    flush();
+    assert.equal(element.getAttribute('title'), '');
+  });
+});
+
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
deleted file mode 100644
index 077ca74..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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,
-        value: false,
-        reflectToAttribute: true,
-      },
-      removable: {
-        type: Boolean,
-        value: false,
-      },
-      text: String,
-      transparentBackground: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**  If provided, sets the maximum length of the content. */
-      limit: Number,
-    };
-  }
-
-  _getBackgroundClass(transparent) {
-    return transparent ? 'transparentBackground' : '';
-  }
-
-  _handleRemoveTap(e) {
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('remove', {
-      composed: true, bubbles: true,
-    }));
-  }
-}
-
-customElements.define(GrLinkedChip.is, GrLinkedChip);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
new file mode 100644
index 0000000..dbb8725
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -0,0 +1,74 @@
+/**
+ * @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 '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import '../gr-limited-text/gr-limited-text';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-linked-chip_html';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-linked-chip': GrLinkedChip;
+  }
+}
+
+@customElement('gr-linked-chip')
+export class GrLinkedChip extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  href?: string;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  @property({type: Boolean})
+  removable = false;
+
+  @property({type: String})
+  text?: string;
+
+  @property({type: Boolean})
+  transparentBackground = false;
+
+  /**  If provided, sets the maximum length of the content. */
+  @property({type: Number})
+  limit?: number;
+
+  _getBackgroundClass(transparent: boolean) {
+    return transparent ? 'transparentBackground' : '';
+  }
+
+  _handleRemoveTap(e: Event) {
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('remove', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+}
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
deleted file mode 100644
index f1f5f46..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
new file mode 100644
index 0000000..a335db7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
@@ -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';
+
+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
deleted file mode 100644
index c8de3df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
+++ /dev/null
@@ -1,65 +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">
-<meta charset="utf-8">
-<title>gr-linked-chip</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>
-<!-- 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>
-    <gr-linked-chip></gr-linked-chip>
-  </template>
-</test-fixture>
-
-<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);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
new file mode 100644
index 0000000..dd2b98a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-linked-chip.js';
+
+const basicFixture = fixtureFromElement('gr-linked-chip');
+
+suite('gr-linked-chip tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('remove fired', () => {
+    const spy = sinon.spy();
+    element.addEventListener('remove', spy);
+    flush();
+    MockInteractions.tap(element.$.remove);
+    assert.isTrue(spy.called);
+  });
+});
+
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
deleted file mode 100644
index 6ea4b78..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.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 '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';
-
-/** @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,
-        observer: '_contentChanged',
-      },
-      pre: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      config: Object,
-    };
-  }
-
-  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;
-  }
-
-  /**
-   * 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 = 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.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
new file mode 100644
index 0000000..e2c2d7f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -0,0 +1,145 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-linked-text_html';
+import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-linked-text': GrLinkedText;
+  }
+}
+
+export interface GrLinkedText {
+  $: {
+    output: HTMLSpanElement;
+  };
+}
+
+@customElement('gr-linked-text')
+export class GrLinkedText extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean})
+  removeZeroWidthSpace?: boolean;
+
+  // content default is null, because this.$.output.textContent is string|null
+  @property({type: String})
+  content: string | null = null;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  pre = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  @property({type: Object})
+  config?: LinkTextParserConfig;
+
+  @observe('content')
+  _contentChanged(content: string | null) {
+    // 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.$.output.textContent = content;
+  }
+
+  /**
+   * Because either the source text or the linkification config has changed,
+   * the content should be re-parsed.
+   *
+   * @param content The raw, un-linkified source string to parse.
+   * @param config The server config specifying commentLink patterns
+   */
+  @observe('content', 'config')
+  _contentOrConfigChanged(
+    content: string | null,
+    config?: LinkTextParserConfig
+  ) {
+    if (!config) {
+      return;
+    }
+
+    // TODO(TS): mapCommentlinks always has value, remove
+    if (!GerritNav.mapCommentlinks) return;
+    config = GerritNav.mapCommentlinks(config);
+    const output = this.$.output;
+    output.textContent = '';
+    const parser = new GrLinkTextParser(
+      config,
+      (text: string | null, href: string | null, fragment?: DocumentFragment) =>
+        this._handleParseResult(text, href, fragment),
+      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.
+   */
+  private _handleParseResult(
+    text: string | null,
+    href: string | null,
+    fragment?: DocumentFragment
+  ) {
+    const output = this.$.output;
+    if (href) {
+      const a = document.createElement('a');
+      a.setAttribute('href', href);
+      // GrLinkTextParser either pass text and href together or
+      // only DocumentFragment - see LinkTextParserCallback
+      a.textContent = text!;
+      a.target = '_blank';
+      a.setAttribute('rel', 'noopener');
+      output.appendChild(a);
+    } else if (fragment) {
+      output.appendChild(fragment);
+    }
+  }
+}
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
deleted file mode 100644
index 59bed1e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
new file mode 100644
index 0000000..4bdc1ab
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 4fa4390..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ /dev/null
@@ -1,376 +0,0 @@
-<!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">
-<meta charset="utf-8">
-<title>gr-linked-text</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-linked-text>
-      <div id="output"></div>
-    </gr-linked-text>
-  </template>
-</test-fixture>
-
-<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';
-
-suite('gr-linked-text tests', () => {
-  let element;
-  let sandbox;
-
-  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/gr-linked-text_test.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
new file mode 100644
index 0000000..a67fbc4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
@@ -0,0 +1,365 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-linked-text.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-linked-text>
+      <div id="output"></div>
+    </gr-linked-text>
+`);
+
+suite('gr-linked-text tests', () => {
+  let element;
+
+  let originalCanonicalPath;
+
+  setup(() => {
+    originalCanonicalPath = window.CANONICAL_PATH;
+    element = basicFixture.instantiate();
+
+    sinon.stub(GerritNav, 'mapCommentlinks').value( 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(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  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 = 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 = sinon.stub(element, '_contentChanged');
+    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
+    element.content = 'some text';
+    assert.isTrue(contentStub.called);
+    assert.isTrue(contentConfigStub.called);
+  });
+});
+
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
deleted file mode 100644
index 6f8a88a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ /dev/null
@@ -1,356 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-
-/**
- * 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);
-  }
-
-  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),
-    });
-  }
-};
-
-/**
- * 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);
-      }
-    }
-
-    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;
-    }
-  }
-  this.processLinks(text, outputArray);
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
new file mode 100644
index 0000000..9066911
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -0,0 +1,428 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import 'ba-linkify/ba-linkify';
+import {getBaseUrl} from '../../../utils/url-util';
+import {CommentLinkInfo} from '../../../types/common';
+
+/**
+ * Pattern describing URLs with supported protocols.
+ */
+const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
+
+export type LinkTextParserCallback = ((text: string, href: string) => void) &
+  ((text: null, href: null, fragment: DocumentFragment) => void);
+
+export interface CommentLinkItem {
+  position: number;
+  length: number;
+  html: HTMLAnchorElement | DocumentFragment;
+}
+
+export type LinkTextParserConfig = {[name: string]: CommentLinkInfo};
+
+export class GrLinkTextParser {
+  private readonly baseUrl = getBaseUrl();
+
+  /**
+   * 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 linkConfig Comment links as specified by the commentlinks field on a
+   *     project config.
+   * @param 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 removeZeroWidthSpace If true, zero-width spaces will be removed from
+   *     R=<email> and CC=<email> expressions.
+   */
+  constructor(
+    private readonly linkConfig: LinkTextParserConfig,
+    private readonly callback: LinkTextParserCallback,
+    private readonly removeZeroWidthSpace?: boolean
+  ) {
+    Object.preventExtensions(this);
+  }
+
+  /**
+   * Emit a callback to create a link element.
+   *
+   * @param text The text of the link.
+   * @param href The URL to use as the href of the link.
+   */
+  addText(text: string, href: string) {
+    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 text The chuml of source text over which the outputArray items range.
+   * @param outputArray The list of items to add resulting from commentlink
+   *     matches.
+   */
+  processLinks(text: string, outputArray: CommentLinkItem[]) {
+    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
+      );
+    }
+
+    this.callback(null, null, fragment);
+  }
+
+  /**
+   * Sort the given array of CommentLinkItems such that the positions are in
+   * reverse order.
+   */
+  sortArrayReverse(outputArray: CommentLinkItem[]) {
+    outputArray.sort((a, b) => b.position - a.position);
+  }
+
+  addItem(
+    text: string,
+    href: string,
+    html: null,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ): void;
+
+  addItem(
+    text: null,
+    href: null,
+    html: string,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ): void;
+
+  /**
+   * 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 text The text to use if creating a link.
+   * @param href The href to use as the URL if creating a link.
+   * @param html The html to parse and use as the result.
+   * @param  position The position inside the source text where the item
+   *     starts.
+   * @param length The number of characters in the source text
+   *     represented by the item.
+   * @param outputArray The array to which the
+   *     new item is to be appended.
+   */
+  addItem(
+    text: string | null,
+    href: string | null,
+    html: string | null,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ): void {
+    if (href) {
+      const a = document.createElement('a');
+      a.setAttribute('href', href);
+      a.textContent = text;
+      a.target = '_blank';
+      a.rel = 'noopener';
+      outputArray.push({
+        html: a,
+        position,
+        length,
+      });
+    } else if (html) {
+      // addItem has 2 overloads. If href is null, then html
+      // can't be null.
+      // TODO(TS): remove if(html) and keep else block without condition
+      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);
+      }
+      outputArray.push({
+        html: fragment,
+        position,
+        length,
+      });
+    }
+  }
+
+  /**
+   * Create a CommentLinkItem for a link and append it to the given output
+   * array.
+   *
+   * @param text The text for the link.
+   * @param href The href to use as the URL of the link.
+   * @param position The position inside the source text where the link
+   *     starts.
+   * @param length The number of characters in the source text
+   *     represented by the link.
+   * @param outputArray The array to which the
+   *     new item is to be appended.
+   */
+  addLink(
+    text: string,
+    href: string,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ) {
+    // TODO(TS): remove !test condition
+    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 html The html to parse and use as the result.
+   * @param position The position inside the source text where the item
+   *     starts.
+   * @param length The number of characters in the source text
+   *     represented by the item.
+   * @param outputArray The array to which the
+   *     new item is to be appended.
+   */
+  addHTML(
+    html: string,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ) {
+    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.
+   */
+  hasOverlap(position: number, length: number, outputArray: CommentLinkItem[]) {
+    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.
+   */
+  parse(text?: string | null) {
+    if (text) {
+      window.linkify(text, {
+        callback: (text: string, href?: string) => this.parseChunk(text, href),
+      });
+    }
+  }
+
+  /**
+   * 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.
+   *
+   */
+  parseChunk(text: string, href?: string) {
+    // 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 text The raw source text.
+   * @param config A comment links specification object.
+   */
+  parseLinks(text: string, config: LinkTextParserConfig) {
+    // The outputArray is used to store all of the matches found for all
+    // patterns.
+    const outputArray: CommentLinkItem[] = [];
+    for (const p in config) {
+      // TODO(TS): it seems, the following line can be rewritten as:
+      // if(enabled === false || enabled === 0 || enabled === '')
+      // Should be double-checked before update
+      // eslint-disable-next-line eqeqeq
+      if (config[p].enabled != null && config[p].enabled == false) {
+        continue;
+      }
+      // PolyGerrit doesn't use hash-based navigation like the GWT UI.
+      // Account for this.
+      const html = config[p].html;
+      const link = config[p].link;
+      if (html) {
+        config[p].html = html.replace(/<a href="#\//g, '<a href="/');
+      } else if (link) {
+        if (link[0] === '#') {
+          config[p].link = link.substr(1);
+        }
+      }
+
+      const pattern = new RegExp(config[p].match, 'g');
+
+      let match;
+      let textToCheck = text;
+      let susbtrIndex = 0;
+
+      while ((match = pattern.exec(textToCheck))) {
+        textToCheck = textToCheck.substr(match.index + match[0].length);
+        let result = match[0].replace(
+          pattern,
+          // Either html or link has a value. Otherwise an exception is thrown
+          // in the code below.
+          (config[p].html || config[p].link)!
+        );
+
+        if (config[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 (config[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;
+      }
+    }
+    this.processLinks(text, outputArray);
+  }
+}
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
deleted file mode 100644
index 7a44c67..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
+++ /dev/null
@@ -1,130 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
-
-/**
- * @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,
-      filter: {
-        type: String,
-        observer: '_filterChanged',
-      },
-      offset: Number,
-      loading: Boolean,
-      path: String,
-    };
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.cancelDebouncer('reload');
-  }
-
-  _filterChanged(newFilter, oldFilter) {
-    if (!newFilter && !oldFilter) {
-      return;
-    }
-
-    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.dispatchEvent(new CustomEvent('create-clicked', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _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;
-  }
-
-  _computeCreateClass(createNew) {
-    return createNew ? 'show' : '';
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
new file mode 100644
index 0000000..342e937
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -0,0 +1,149 @@
+/**
+ * @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-input/iron-input';
+import '@polymer/iron-icon/iron-icon';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-list-view_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {page} from '../../../utils/page-wrapper-utils';
+import {property, observe, customElement} from '@polymer/decorators';
+
+const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-list-view': GrListView;
+  }
+}
+
+@customElement('gr-list-view')
+class GrListView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean})
+  createNew?: boolean;
+
+  @property({type: Array})
+  items?: unknown[];
+
+  @property({type: Number})
+  itemsPerPage = 25;
+
+  @property({type: String})
+  filter?: string;
+
+  @property({type: Number})
+  offset?: number;
+
+  @property({type: Boolean})
+  loading?: boolean;
+
+  @property({type: String})
+  path?: string;
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancelDebouncer('reload');
+  }
+
+  @observe('filter')
+  _filterChanged(newFilter: string, oldFilter: string) {
+    if (!newFilter && !oldFilter) {
+      return;
+    }
+
+    this._debounceReload(newFilter);
+  }
+
+  _debounceReload(filter: string) {
+    this.debounce(
+      'reload',
+      () => {
+        if (this.path) {
+          if (filter) {
+            return page.show(
+              `${this.path}/q/filter:` + encodeURL(filter, false)
+            );
+          }
+          return page.show(this.path);
+        }
+      },
+      REQUEST_DEBOUNCE_INTERVAL_MS
+    );
+  }
+
+  _createNewItem() {
+    this.dispatchEvent(
+      new CustomEvent('create-clicked', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _computeNavLink(
+    offset: number,
+    direction: number,
+    itemsPerPage: number,
+    filter: string,
+    path: string
+  ) {
+    // Offset could be a string when passed from the router.
+    offset = +(offset || 0);
+    const newOffset = Math.max(0, offset + itemsPerPage * direction);
+    let href = getBaseUrl() + path;
+    if (filter) {
+      href += '/q/filter:' + encodeURL(filter, false);
+    }
+    if (newOffset > 0) {
+      href += `,${newOffset}`;
+    }
+    return href;
+  }
+
+  _computeCreateClass(createNew?: boolean) {
+    return createNew ? 'show' : '';
+  }
+
+  _hidePrevArrow(loading?: boolean, offset?: number) {
+    return loading || offset === 0;
+  }
+
+  _hideNextArrow(loading?: boolean, items?: unknown[]) {
+    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: number, itemsPerPage: number) {
+    return offset / itemsPerPage + 1;
+  }
+}
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
deleted file mode 100644
index ff73d4d8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts
new file mode 100644
index 0000000..75ee667
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 55aab82..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
+++ /dev/null
@@ -1,166 +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">
-<meta charset="utf-8">
-<title>gr-list-view</title>
-
-<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>
-    <gr-list-view></gr-list-view>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-list-view.js';
-import page from 'page/page.mjs';
-
-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';
-  });
-
-  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;
-    let 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.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-list-view/gr-list-view_test.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
new file mode 100644
index 0000000..066c53e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
@@ -0,0 +1,147 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-list-view.js';
+import {page} from '../../../utils/page-wrapper-utils.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-list-view');
+
+suite('gr-list-view tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeNavLink', () => {
+    const offset = 25;
+    const projectsPerPage = 25;
+    let filter = 'test';
+    const path = '/admin/projects';
+
+    stubBaseUrl('');
+
+    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', async () => {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    element.path = '/admin/projects';
+    sinon.stub(page, 'show').callsFake(resolve);
+
+    element.filter = 'test';
+
+    const url = await promise;
+    assert.equal(url, '/admin/projects/q/filter:test');
+  });
+
+  test('_filterChanged not reload when swap between falsy values', () => {
+    sinon.stub(element, '_debounceReload');
+    element.filter = null;
+    element.filter = undefined;
+    element.filter = '';
+    assert.isFalse(element._debounceReload.called);
+  });
+
+  test('next button', () => {
+    element.itemsPerPage = 25;
+    let 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));
+  });
+
+  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;
+    flush();
+    assert.isTrue(element.shadowRoot
+        .querySelector('#createNewContainer').classList
+        .contains('show'));
+  });
+
+  test('fires create clicked event when button tapped', () => {
+    const clickHandler = sinon.stub();
+    element.addEventListener('create-clicked', clickHandler);
+    element.createNew = true;
+    flush();
+    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';
+    sinon.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);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
deleted file mode 100644
index a5a3fb4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
-
-/**
- * @extends Polymer.Element
- */
-class GrOverlay extends mixinBehaviors( [
-  IronOverlayBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-overlay'; }
-  /**
-   * Fired when a fullscreen overlay is closed
-   *
-   * @event fullscreen-overlay-closed
-   */
-
-  /**
-   * Fired when an overlay is opened in full screen mode
-   *
-   * @event fullscreen-overlay-opened
-   */
-
-  static get properties() {
-    return {
-      _fullScreenOpen: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('iron-overlay-closed',
-        () => this._close());
-    this.addEventListener('iron-overlay-cancelled',
-        () => this._close());
-  }
-
-  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);
-    });
-  }
-
-  _isMobile() {
-    return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
-  }
-
-  _close() {
-    if (this._fullScreenOpen) {
-      this.dispatchEvent(new CustomEvent('fullscreen-overlay-closed', {
-        composed: true, bubbles: true,
-      }));
-      this._fullScreenOpen = false;
-    }
-  }
-
-  /**
-   * 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.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
new file mode 100644
index 0000000..957496c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -0,0 +1,161 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-overlay_html';
+import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-overlay': GrOverlay;
+  }
+}
+
+@customElement('gr-overlay')
+export class GrOverlay extends IronOverlayMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement)),
+  IronOverlayBehavior as IronOverlayBehavior
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when a fullscreen overlay is closed
+   *
+   * @event fullscreen-overlay-closed
+   */
+
+  /**
+   * Fired when an overlay is opened in full screen mode
+   *
+   * @event fullscreen-overlay-opened
+   */
+
+  @property({type: Boolean})
+  private _fullScreenOpen = false;
+
+  private _boundHandleClose: () => void = () => super.close();
+
+  private focusableNodes: Node[] | undefined;
+
+  get _focusableNodes() {
+    if (this.focusableNodes) {
+      return this.focusableNodes;
+    }
+    // TODO(TS): to avoid ts error for:
+    // Only public and protected methods of the base class are accessible
+    // via the 'super' keyword.
+    // we call IronFocsablesHelper directly here
+    // Currently IronFocsablesHelper is not exported from iron-focusables-helper
+    // as it should so we use Polymer.IronFocsablesHelper here instead
+    // (can not use the IronFocsablesHelperClass
+    // in case different behavior due to singleton)
+    // once the type contains the exported member,
+    // should replace with:
+    // import {IronFocusablesHelper} from '@polymer/iron-overlay-behavior/iron-focusables-helper';
+    return (window.Polymer as any).IronFocusablesHelper.getTabbableNodes(this);
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('iron-overlay-closed', () => this._overlayClosed());
+    this.addEventListener('iron-overlay-cancelled', () =>
+      this._overlayClosed()
+    );
+  }
+
+  open() {
+    window.addEventListener('popstate', this._boundHandleClose);
+    return new Promise((resolve, reject) => {
+      super.open.apply(this);
+      if (this._isMobile()) {
+        this.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-opened', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        this._fullScreenOpen = true;
+      }
+      this._awaitOpen(resolve, reject);
+    });
+  }
+
+  _isMobile() {
+    return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
+  }
+
+  // called after iron-overlay is closed. Does not actually close the overlay
+  _overlayClosed() {
+    window.removeEventListener('popstate', this._boundHandleClose);
+    if (this._fullScreenOpen) {
+      this.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-closed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      this._fullScreenOpen = false;
+    }
+  }
+
+  /**
+   * Override the focus stops that iron-overlay-behavior tries to find.
+   */
+  setFocusStops(stops: GrOverlayStops) {
+    this.focusableNodes = [stops.start, 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: (this: GrOverlay) => void, reject: (error: Error) => void) {
+    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';
+  }
+}
+
+export interface GrOverlayStops {
+  start: Node;
+  end: Node;
+}
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
deleted file mode 100644
index 7123adb..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
new file mode 100644
index 0000000..730eeac
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
@@ -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';
+
+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
deleted file mode 100644
index d43c739..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
+++ /dev/null
@@ -1,91 +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">
-<meta charset="utf-8">
-<title>gr-overlay</title>
-
-<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>
-    <gr-overlay>
-      <div>content</div>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  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);
-
-    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();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
new file mode 100644
index 0000000..4b6ae34
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
@@ -0,0 +1,86 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-overlay.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-overlay>
+      <div>content</div>
+    </gr-overlay>
+`);
+
+suite('gr-overlay tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('popstate listener is attached on open and removed on close', () => {
+    const addEventListenerStub = sinon.stub(window, 'addEventListener');
+    const removeEventListenerStub = sinon.stub(window, 'removeEventListener');
+    element.open();
+    assert.isTrue(addEventListenerStub.called);
+    assert.equal(addEventListenerStub.lastCall.args[0], 'popstate');
+    assert.equal(addEventListenerStub.lastCall.args[1],
+        element._boundHandleClose);
+    element._overlayClosed();
+    assert.isTrue(removeEventListenerStub.called);
+    assert.equal(removeEventListenerStub.lastCall.args[0], 'popstate');
+    assert.equal(removeEventListenerStub.lastCall.args[1],
+        element._boundHandleClose);
+  });
+
+  test('events are fired on fullscreen view', async () => {
+    sinon.stub(element, '_isMobile').returns(true);
+    const openHandler = sinon.stub();
+    const closeHandler = sinon.stub();
+    element.addEventListener('fullscreen-overlay-opened', openHandler);
+    element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+    await element.open();
+
+    assert.isTrue(element._isMobile.called);
+    assert.isTrue(element._fullScreenOpen);
+    assert.isTrue(openHandler.called);
+
+    element._overlayClosed();
+    assert.isFalse(element._fullScreenOpen);
+    assert.isTrue(closeHandler.called);
+  });
+
+  test('events are not fired on desktop view', async () => {
+    sinon.stub(element, '_isMobile').returns(false);
+    const openHandler = sinon.stub();
+    const closeHandler = sinon.stub();
+    element.addEventListener('fullscreen-overlay-opened', openHandler);
+    element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+    await element.open();
+
+    assert.isTrue(element._isMobile.called);
+    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(openHandler.called);
+
+    element._overlayClosed();
+    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(closeHandler.called);
+  });
+});
+
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
deleted file mode 100644
index 8366463..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
+++ /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.
- */
-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';
-
-/** @extends Polymer.Element */
-class GrPageNav extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-page-nav'; }
-
-  static get properties() {
-    return {
-      _headerHeight: Number,
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.listen(window, 'scroll', '_handleBodyScroll');
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'scroll', '_handleBodyScroll');
-  }
-
-  _handleBodyScroll() {
-    if (this._headerHeight === undefined) {
-      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);
-  }
-
-  /* Functions used for test purposes */
-  _getOffsetParent(element) {
-    if (!element || !element.offsetParent) { return ''; }
-    return element.offsetParent;
-  }
-
-  _getOffsetTop(element) {
-    return element.offsetTop;
-  }
-
-  _getScrollY() {
-    return window.scrollY;
-  }
-}
-
-customElements.define(GrPageNav.is, GrPageNav);
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
new file mode 100644
index 0000000..e009499
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
@@ -0,0 +1,100 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-page-nav_html';
+import {customElement, property} from '@polymer/decorators';
+
+/**
+ * Augment the interface on top of PolymerElement
+ * for gr-page-nav.
+ */
+export interface GrPageNav {
+  $: {
+    // Note: this is needed to access $.nav
+    // with dotted property access
+    nav: HTMLElement;
+  };
+}
+
+@customElement('gr-page-nav')
+export class GrPageNav extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Number})
+  _headerHeight?: number;
+
+  private readonly bodyScrollHandler: () => void;
+
+  constructor() {
+    super();
+    this.bodyScrollHandler = () => this._handleBodyScroll();
+  }
+
+  attached() {
+    super.attached();
+    window.addEventListener('scroll', this.bodyScrollHandler);
+  }
+
+  detached() {
+    super.detached();
+    window.removeEventListener('scroll', this.bodyScrollHandler);
+  }
+
+  _handleBodyScroll() {
+    if (this._headerHeight === undefined) {
+      let top = this._getOffsetTop(this);
+      // TODO(TS): Element doesn't have offsetParent,
+      // while `offsetParent` are returning Element not HTMLElement
+      for (
+        let offsetParent = this.offsetParent as HTMLElement | undefined;
+        offsetParent;
+        offsetParent = this._getOffsetParent(offsetParent)
+      ) {
+        top += this._getOffsetTop(offsetParent);
+      }
+      this._headerHeight = top;
+    }
+
+    this.$.nav.classList.toggle(
+      'pinned',
+      this._getScrollY() >= (this._headerHeight || 0)
+    );
+  }
+
+  /* Functions used for test purposes */
+  _getOffsetParent(element?: HTMLElement) {
+    if (!element || !('offsetParent' in element)) {
+      return undefined;
+    }
+    return element.offsetParent as HTMLElement;
+  }
+
+  _getOffsetTop(element: HTMLElement) {
+    return element.offsetTop;
+  }
+
+  _getScrollY() {
+    return window.scrollY;
+  }
+}
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
deleted file mode 100644
index c5e9142..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
new file mode 100644
index 0000000..facc4f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
@@ -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';
+
+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
deleted file mode 100644
index a54d7a3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
+++ /dev/null
@@ -1,89 +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">
-<meta charset="utf-8">
-<title>gr-page-nav</title>
-
-<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>
-    <gr-page-nav>
-      <ul>
-        <li>item</li>
-      </ul>
-    </gr-page-nav>
-  </template>
-</test-fixture>
-
-<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'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
new file mode 100644
index 0000000..2960a1f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
@@ -0,0 +1,71 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-page-nav.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-page-nav>
+      <ul>
+        <li>item</li>
+      </ul>
+    </gr-page-nav>
+`);
+
+suite('gr-page-nav tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    flush();
+  });
+
+  test('header is not pinned just below top', () => {
+    sinon.stub(element, '_getOffsetParent').callsFake(() => 0);
+    sinon.stub(element, '_getOffsetTop').callsFake(() => 10);
+    sinon.stub(element, '_getScrollY').callsFake(() => 5);
+    element._handleBodyScroll();
+    assert.isFalse(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page', () => {
+    sinon.stub(element, '_getOffsetParent').callsFake(() => 0);
+    sinon.stub(element, '_getOffsetTop').callsFake(() => 10);
+    sinon.stub(element, '_getScrollY').callsFake(() => 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;
+    sinon.stub(element, '_getScrollY').callsFake(() => 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;
+    sinon.stub(element, '_getScrollY').callsFake(() => 25);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isTrue(element.$.nav.classList.contains('pinned'));
+  });
+});
+
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
deleted file mode 100644
index c499f56..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
+++ /dev/null
@@ -1,134 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-const SUGGESTIONS_LIMIT = 15;
-const REF_PREFIX = 'refs/heads/';
-
-/**
- * @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,
-        observer: '_repoChanged',
-      },
-      branch: {
-        type: String,
-        notify: true,
-      },
-      _branchDisabled: Boolean,
-      _query: {
-        type: Function,
-        value() {
-          return this._getRepoBranchesSuggestions.bind(this);
-        },
-      },
-      _repoQuery: {
-        type: Function,
-        value() {
-          return this._getRepoSuggestions.bind(this);
-        },
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    if (this.repo) {
-      this.$.repoInput.setText(this.repo);
-    }
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._branchDisabled = !this.repo;
-  }
-
-  _getRepoBranchesSuggestions(input) {
-    if (!this.repo) { return Promise.resolve([]); }
-    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));
-  }
-
-  _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};
-    });
-  }
-
-  _repoCommitted(e) {
-    this.repo = e.detail.value;
-  }
-
-  _branchCommitted(e) {
-    this.branch = e.detail.value;
-  }
-
-  _repoChanged() {
-    this.$.branchInput.clear();
-    this._branchDisabled = !this.repo;
-  }
-}
-
-customElements.define(GrRepoBranchPicker.is, GrRepoBranchPicker);
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
new file mode 100644
index 0000000..01eada8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
@@ -0,0 +1,150 @@
+/**
+ * @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.
+ */
+import '@polymer/iron-icon/iron-icon';
+import '../../../styles/shared-styles';
+import '../gr-icons/gr-icons';
+import '../gr-labeled-autocomplete/gr-labeled-autocomplete';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-branch-picker_html';
+import {singleDecodeURL} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {AutocompleteQuery} from '../gr-autocomplete/gr-autocomplete';
+import {
+  BranchName,
+  RepoName,
+  ProjectInfoWithName,
+  BranchInfo,
+} from '../../../types/common';
+import {GrLabeledAutocomplete} from '../gr-labeled-autocomplete/gr-labeled-autocomplete';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+const SUGGESTIONS_LIMIT = 15;
+const REF_PREFIX = 'refs/heads/';
+
+export interface GrRepoBranchPicker {
+  $: {
+    repoInput: GrLabeledAutocomplete;
+    branchInput: GrLabeledAutocomplete;
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-repo-branch-picker')
+export class GrRepoBranchPicker extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, notify: true, observer: '_repoChanged'})
+  repo?: RepoName;
+
+  @property({type: String, notify: true})
+  branch?: BranchName;
+
+  @property({type: Boolean})
+  _branchDisabled?: boolean;
+
+  @property({type: Object})
+  _query?: AutocompleteQuery;
+
+  @property({type: Object})
+  _repoQuery?: AutocompleteQuery;
+
+  constructor() {
+    super();
+    this._query = input => this._getRepoBranchesSuggestions(input);
+    this._repoQuery = input => this._getRepoSuggestions(input);
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    if (this.repo) {
+      this.$.repoInput.setText(this.repo);
+    }
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._branchDisabled = !this.repo;
+  }
+
+  _getRepoBranchesSuggestions(input: string) {
+    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(res => this._branchResponseToSuggestions(res));
+  }
+
+  _getRepoSuggestions(input: string) {
+    return this.$.restAPI
+      .getRepos(input, SUGGESTIONS_LIMIT)
+      .then(res => this._repoResponseToSuggestions(res));
+  }
+
+  _repoResponseToSuggestions(res: ProjectInfoWithName[] | undefined) {
+    if (!res) return [];
+    return res.map(repo => {
+      return {
+        name: repo.name,
+        value: singleDecodeURL(repo.id),
+      };
+    });
+  }
+
+  _branchResponseToSuggestions(res: BranchInfo[] | undefined) {
+    if (!res) return [];
+    return res.map(branchInfo => {
+      let branch;
+      if (branchInfo.ref.startsWith(REF_PREFIX)) {
+        branch = branchInfo.ref.substring(REF_PREFIX.length);
+      } else {
+        branch = branchInfo.ref;
+      }
+      return {name: branch, value: branch};
+    });
+  }
+
+  _repoCommitted(e: CustomEvent<{value: string}>) {
+    this.repo = e.detail.value as RepoName;
+  }
+
+  _branchCommitted(e: CustomEvent<{value: string}>) {
+    this.branch = e.detail.value as BranchName;
+  }
+
+  _repoChanged() {
+    this.$.branchInput.clear();
+    this._branchDisabled = !this.repo;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-branch-picker': 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
deleted file mode 100644
index 0ce885a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
new file mode 100644
index 0000000..934b3cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
@@ -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';
+
+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
deleted file mode 100644
index 67b82f9..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
+++ /dev/null
@@ -1,143 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-<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="/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-repo-branch-picker></gr-repo-branch-picker>
-  </template>
-</test-fixture>
-
-<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.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', 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-repo-branch-picker/gr-repo-branch-picker_test.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
new file mode 100644
index 0000000..1d8ae98
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
@@ -0,0 +1,123 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-branch-picker.js';
+
+const basicFixture = fixtureFromElement('gr-repo-branch-picker');
+
+suite('gr-repo-branch-picker tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('_getRepoSuggestions', () => {
+    setup(() => {
+      sinon.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(() => {
+      sinon.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', () => element
+        ._getRepoBranchesSuggestions('')
+        .then(() => {
+          assert.isFalse(element.$.restAPI.getRepoBranches.called);
+          element.repo = 'gerrit';
+          return element._getRepoBranchesSuggestions('');
+        })
+        .then(() => {
+          assert.isTrue(element.$.restAPI.getRepoBranches.called);
+        }));
+  });
+});
+
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
deleted file mode 100644
index 6663f07..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
+++ /dev/null
@@ -1,273 +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.
- */
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
-
-const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
-const MAX_GET_TOKEN_RETRIES = 2;
-
-/**
- * Auth class.
- */
-export class Auth {
-  // TODO(taoalpha): this whole thing should be moved to a service
-
-  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();
-  }
-
-  get baseUrl() {
-    return BaseUrlBehavior.getBaseUrl();
-  }
-
-  /**
-   * Returns if user is authed or not.
-   *
-   * @returns {!Promise<boolean>}
-   */
-  authCheck() {
-    if (!this._authCheckPromise ||
-      (Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS)
-    ) {
-      // Refetch after last check expired
-      this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
-      this._last_auth_check_time = Date.now();
-    }
-
-    return this._authCheckPromise.then(res => {
-      // auth-check will return 204 if authed
-      // treat the rest as unauthed
-      if (res.status === 204) {
-        this._setStatus(Auth.STATUS.AUTHED);
-        return true;
-      } else {
-        this._setStatus(Auth.STATUS.NOT_AUTHED);
-        return false;
-      }
-    }).catch(e => {
-      this._setStatus(Auth.STATUS.ERROR);
-      // Reset _authCheckPromise to avoid caching the failed promise
-      this._authCheckPromise = null;
-      return false;
-    });
-  }
-
-  clearCache() {
-    this._authCheckPromise = null;
-  }
-
-  /**
-   * @param {Auth.STATUS} status
-   */
-  _setStatus(status) {
-    if (this._status === status) return;
-
-    if (this._status === Auth.STATUS.AUTHED) {
-      gerritEventEmitter.emit('auth-error', {
-        message: Auth.CREDS_EXPIRED_MSG, action: 'Refresh credentials',
-      });
-    }
-    this._status = status;
-  }
-
-  get status() {
-    return this._status;
-  }
-
-  get isAuthed() {
-    return this._status === Auth.STATUS.AUTHED;
-  }
-
-  _getToken() {
-    return Promise.resolve(this._cachedTokenPromise);
-  }
-
-  /**
-   * Enable cross-domain authentication using OAuth access token.
-   *
-   * @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];
-      }
-    }
-  }
-
-  /**
-   * 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 result;
-  }
-
-  _isTokenValid(token) {
-    if (!token) { return false; }
-    if (!token.access_token || !token.expires_at) { return false; }
-
-    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);
-  }
-
-  /**
-   * @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 (this._retriesLeft > 0) {
-        this._retriesLeft--;
-        this._cachedTokenPromise = null;
-        return this._getAccessToken();
-      }
-      // Fall back to anonymous access.
-      return null;
-    });
-  }
-
-  _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);
-      }
-    }
-
-    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';
-      }
-    }
-
-    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
deleted file mode 100644
index 5fa476f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
+++ /dev/null
@@ -1,394 +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">
-<meta charset="utf-8">
-<title>gr-auth</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 {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';
-
-suite('gr-auth', () => {
-  let auth;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    auth = authService;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('Auth class methods', () => {
-    let fakeFetch;
-    setup(() => {
-      auth = new Auth();
-      fakeFetch = sandbox.stub(window, 'fetch');
-    });
-
-    test('auth-check returns 403', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        done();
-      });
-    });
-
-    test('auth-check returns 204', done => {
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.authCheck().then(authed => {
-        assert.isTrue(authed);
-        assert.equal(auth.status, Auth.STATUS.AUTHED);
-        done();
-      });
-    });
-
-    test('auth-check returns 502', done => {
-      fakeFetch.returns(Promise.resolve({status: 502}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        done();
-      });
-    });
-
-    test('auth-check failed', done => {
-      fakeFetch.returns(Promise.reject(new Error('random error')));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.ERROR);
-        done();
-      });
-    });
-  });
-
-  suite('cache and events behaivor', () => {
-    let fakeFetch;
-    let clock;
-    setup(() => {
-      auth = new Auth();
-      clock = sinon.useFakeTimers();
-      fakeFetch = sandbox.stub(window, 'fetch');
-    });
-
-    test('cache auth-check result', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          done();
-        });
-      });
-    });
-
-    test('clearCache should refetch auth-check result', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.clearCache();
-        auth.authCheck().then(authed2 => {
-          assert.isTrue(authed2);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          done();
-        });
-      });
-    });
-
-    test('cache expired on auth-check after certain time', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.authCheck().then(authed2 => {
-          assert.isTrue(authed2);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          done();
-        });
-      });
-    });
-
-    test('no cache if auth-check failed', done => {
-      fakeFetch.returns(Promise.reject(new Error('random error')));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.ERROR);
-        assert.equal(fakeFetch.callCount, 1);
-        auth.authCheck().then(() => {
-          assert.equal(fakeFetch.callCount, 2);
-          done();
-        });
-      });
-    });
-
-    test('fire event when switch from authed to unauthed', done => {
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.authCheck().then(authed => {
-        assert.isTrue(authed);
-        assert.equal(auth.status, Auth.STATUS.AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.resolve({status: 403}));
-        const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed2);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          assert.isTrue(emitStub.called);
-          done();
-        });
-      });
-    });
-
-    test('fire event when switch from authed to error', done => {
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.authCheck().then(authed => {
-        assert.isTrue(authed);
-        assert.equal(auth.status, Auth.STATUS.AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.reject(new Error('random error')));
-        const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed2);
-          assert.isTrue(emitStub.called);
-          assert.equal(auth.status, Auth.STATUS.ERROR);
-          done();
-        });
-      });
-    });
-
-    test('no event from non-authed to other status', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
-        auth.authCheck().then(authed2 => {
-          assert.isTrue(authed2);
-          assert.isFalse(emitStub.called);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          done();
-        });
-      });
-    });
-
-    test('no event from non-authed to other status', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.reject(new Error('random error')));
-        const emitStub = sinon.stub();
-        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.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
deleted file mode 100644
index 23b8de7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
+++ /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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-etag-decorator">
-  
-</dom-module>`;
-
-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;
-};
-
-/**
- * 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);
-  }
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
new file mode 100644
index 0000000..2c1ba70
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
@@ -0,0 +1,98 @@
+/**
+ * @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.
+ */
+
+// Limit cache size because /change/detail responses may be large.
+const MAX_CACHE_SIZE = 30;
+
+/**
+ * Option to send with etag requests.
+ */
+export interface ETagOption {
+  headers?: Headers;
+}
+
+/**
+ * GrTagDecorator class.
+ *
+ * Defines common methods to help cache and build ETag into a request header.
+ */
+export class GrEtagDecorator {
+  _etags = new Map<string, string | null>();
+
+  _payloadCache = new Map<string, string>();
+
+  /**
+   * Get or upgrade fetch options to include an ETag in a request.
+   *
+   */
+  getOptions(url: string, options?: ETagOption) {
+    const etag = this._etags.get(url);
+    if (!etag) {
+      return options;
+    }
+    const optionsCopy: ETagOption = {...options};
+    optionsCopy.headers = optionsCopy.headers || new Headers();
+    optionsCopy.headers.set('If-None-Match', etag);
+    return optionsCopy;
+  }
+
+  /**
+   * Handle a response to a request with ETag headers, potentially incorporating
+   * its result in the payload cache.
+   *
+   *
+   * @param url The URL of the request.
+   * @param response The response object.
+   * @param payload The raw, unparsed JSON contained in the response
+   *     body. Note: because response.text() cannot be read twice, this must be
+   *     provided separately.
+   */
+  collect(url: string, response: Response, payload: string) {
+    if (!response || !response.ok || response.status !== 200) {
+      // 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.
+   */
+  getCachedPayload(url: string) {
+    return this._payloadCache.get(url);
+  }
+
+  /**
+   * Limit the cache size to MAX_CACHE_SIZE.
+   */
+  _truncateCache() {
+    for (const url of this._etags.keys()) {
+      if (this._etags.size <= MAX_CACHE_SIZE) {
+        break;
+      }
+      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
deleted file mode 100644
index cfa164f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
+++ /dev/null
@@ -1,99 +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">
-<meta charset="utf-8">
-<title>gr-etag-decorator</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 {GrEtagDecorator} from './gr-etag-decorator.js';
-
-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};
-  };
-
-  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-etag-decorator_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
new file mode 100644
index 0000000..f4099ec
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.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 '../../../test/common-test-setup-karma.js';
+import 'lodash/lodash.js';
+import {GrEtagDecorator} from './gr-etag-decorator.js';
+
+suite('gr-etag-decorator', () => {
+  let etag;
+
+  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(() => {
+    etag = new GrEtagDecorator();
+  });
+
+  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');
+  });
+});
+
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
deleted file mode 100644
index c10e594..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ /dev/null
@@ -1,2835 +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.
- */
-/* 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';
-
-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 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 Requests = {
-  SEND_DIFF_DRAFT: 'sendDiffDraft',
-};
-
-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 ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
-const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
-    '/revisions/*';
-
-/**
- * @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 a network error occurs.
-   *
-   * @event network-error
-   */
-
-  /**
-   * Fired after an RPC completes.
-   *
-   * @event rpc-log
-   */
-
-  constructor() {
-    super();
-    this.JSON_PREFIX = JSON_PREFIX;
-  }
-
-  static get properties() {
-    return {
-      _cache: {
-        type: Object,
-        value: new SiteBasedCache(), // Shared across instances.
-      },
-      _sharedFetchPromises: {
-        type: Object,
-        value: new FetchPromisesCache(), // Shared across instances.
-      },
-      _pendingRequests: {
-        type: Object,
-        value: {}, // Intentional to share the object across instances.
-      },
-      _etags: {
-        type: Object,
-        value: new GrEtagDecorator(), // Share across instances.
-      },
-      /**
-       * Used to maintain a mapping of changeNums to project names.
-       */
-      _projectLookup: {
-        type: Object,
-        value: {}, // Intentional to share the object across instances.
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this._auth = authService;
-    this._initRestApiHelper();
-  }
-
-  _initRestApiHelper() {
-    if (this._restApiHelper) {
-      return;
-    }
-    if (this._cache && this._auth && this._sharedFetchPromises) {
-      this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
-          this._sharedFetchPromises, 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({
-      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/*',
-    });
-  }
-
-  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',
-    });
-  }
-
-  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=*',
-    });
-  }
-
-  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',
-    });
-  }
-
-  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',
-    });
-  }
-
-  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
-   */
-  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/*',
-    });
-  }
-
-  /**
-   * @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/*',
-    });
-  }
-
-  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
-   */
-  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} 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} 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} 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} 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));
-  }
-
-  getGroupMembers(groupName, opt_errFn) {
-    const encodeName = encodeURIComponent(groupName);
-    return this._restApiHelper.fetchJSON({
-      url: `/groups/${encodeName}/members/`,
-      errFn: opt_errFn,
-      anonymizedUrl: '/groups/*/members',
-    });
-  }
-
-  getIncludedGroup(groupName) {
-    return this._restApiHelper.fetchJSON({
-      url: `/groups/${encodeURIComponent(groupName)}/groups/`,
-      anonymizedUrl: '/groups/*/groups',
-    });
-  }
-
-  saveGroupName(groupId, name) {
-    const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: 'PUT',
-      url: `/groups/${encodeId}/name`,
-      body: {name},
-      anonymizedUrl: '/groups/*/name',
-    });
-  }
-
-  saveGroupOwner(groupId, ownerId) {
-    const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: 'PUT',
-      url: `/groups/${encodeId}/owner`,
-      body: {owner: ownerId},
-      anonymizedUrl: '/groups/*/owner',
-    });
-  }
-
-  saveGroupDescription(groupId, description) {
-    const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: 'PUT',
-      url: `/groups/${encodeId}/description`,
-      body: {description},
-      anonymizedUrl: '/groups/*/description',
-    });
-  }
-
-  saveGroupOptions(groupId, options) {
-    const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: 'PUT',
-      url: `/groups/${encodeId}/options`,
-      body: options,
-      anonymizedUrl: '/groups/*/options',
-    });
-  }
-
-  getGroupAuditLog(group, opt_errFn) {
-    return this._fetchSharedCacheURL({
-      url: '/groups/' + group + '/log.audit',
-      errFn: opt_errFn,
-      anonymizedUrl: '/groups/*/log.audit',
-    });
-  }
-
-  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/*',
-    });
-  }
-
-  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);
-      }
-    });
-  }
-
-  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();
-    }
-
-    return this._restApiHelper.send({
-      method: 'PUT',
-      url: '/accounts/self/preferences',
-      body: prefs,
-      errFn: opt_errFn,
-      reportUrlAsIs: true,
-    });
-  }
-
-  /**
-   * @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,
-    });
-  }
-
-  /**
-   * @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,
-    });
-  }
-
-  getAccount() {
-    return this._fetchSharedCacheURL({
-      url: '/accounts/self/detail',
-      reportUrlAsIs: true,
-      errFn: resp => {
-        if (!resp || resp.status === 403) {
-          this._cache.delete('/accounts/self/detail');
-        }
-      },
-    });
-  }
-
-  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 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);
-      }
-    });
-  }
-
-  /**
-   * @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();
-          }
-        })
-        .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;
-          }
-          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,
-      });
-    });
-  }
-
-  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_DIFFSTAT
-      );
-    }
-    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};
-      const req = {
-        url,
-        errFn: opt_errFn,
-        cancelCondition: opt_cancelCondition,
-        params,
-        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)));
-        }
-
-        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;
-        });
-      });
-    });
-  }
-
-  /**
-   * @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) {
-    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;
-    }
-
-    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/?');
-  }
-
-  invalidateAccountsCache() {
-    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
-  }
-
-  invalidateAccountsDetailCache() {
-    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
-  }
-
-  /**
-   * @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);
-
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
-      url,
-      anonymizedUrl: '/projects/?*',
-    });
-  }
-
-  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',
-    });
-  }
-
-  /**
-   * @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?*',
-    });
-  }
-
-  /**
-   * @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',
-    });
-  }
-
-  /**
-   * @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',
-    });
-  }
-
-  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',
-    });
-  }
-
-  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{string} opt_p
-   * @param {number} opt_n
-   * @param {function(?Response, string=)=} opt_errFn
-   */
-  getSuggestedGroups(inputVal, opt_p, opt_n, opt_errFn) {
-    const params = {s: inputVal};
-    if (opt_n) { params.n = opt_n; }
-    if (opt_p) { params.p = opt_p; }
-    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: '/edit/',
-        params,
-        reportEndpointAsIs: true,
-      }, true);
-    });
-  }
-
-  /**
-   * @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 {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);
-
-    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/*',
-    });
-  }
-
-  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: 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,
-      url,
-      body: opt_body,
-      errFn: opt_errFn,
-      contentType: opt_contentType,
-      headers: opt_headers,
-    });
-  }
-
-  /**
-   * @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',
-    };
-
-    // 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);
-    });
-  }
-
-  _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) {
-    /**
-     * 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.
-     */
-    // 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);
-
-    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);
-    });
-    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);
-      });
-      promises.push(fetchPromise);
-    }
-
-    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] = [];
-        });
-  }
-
-  _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);
-      }
-    } 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;
-      }
-
-      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;
-    });
-  }
-
-  /**
-   * @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,
-    });
-  }
-
-  /**
-   * @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,
-    });
-  }
-
-  deleteAccountHttpPassword() {
-    return this._restApiHelper.send({
-      method: 'DELETE',
-      url: '/accounts/self/password.http',
-      reportUrlAsIs: true,
-    });
-  }
-
-  /**
-   * @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,
-    });
-  }
-
-  getAccountSSHKeys() {
-    return this._fetchSharedCacheURL({
-      url: '/accounts/self/sshkeys',
-      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;
-        });
-  }
-
-  deleteAccountSSHKey(id) {
-    return this._restApiHelper.send({
-      method: 'DELETE',
-      url: '/accounts/self/sshkeys/' + id,
-      anonymizedUrl: '/accounts/self/sshkeys/*',
-    });
-  }
-
-  getAccountGPGKeys() {
-    return this._restApiHelper.fetchJSON({
-      url: '/accounts/self/gpgkeys',
-      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 => 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.';
-      }
-    });
-  }
-
-  /**
-   * @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,
-    });
-  }
-
-  /**
-   * @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',
-    });
-  }
-
-  /**
-   * 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];
-    });
-  }
-
-  /**
-   * @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;
-  }
-
-  /**
-   * 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,
-          errFn: req.errFn,
-          contentType: req.contentType,
-          headers: req.headers,
-          parseResponse: req.parseResponse,
-          anonymizedUrl: anonymizedEndpoint ?
-            (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
-        }));
-  }
-
-  /**
-   * 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,
-    });
-  }
-
-  /**
-   * 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;
-    });
-  }
-
-  /**
-   * 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);
-
-    // 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,
-    });
-  }
-
-  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.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
new file mode 100644
index 0000000..7a39838
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -0,0 +1,3602 @@
+/**
+ * @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.
+ */
+/* NB: Order is important, because of namespaced classes. */
+
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {GrEtagDecorator} from './gr-etag-decorator';
+import {
+  FetchJSONRequest,
+  FetchParams,
+  FetchPromisesCache,
+  GrRestApiHelper,
+  SendJSONRequest,
+  SendRequest,
+  SiteBasedCache,
+} from './gr-rest-apis/gr-rest-api-helper';
+import {
+  GrReviewerUpdatesParser,
+  ParsedChangeInfo,
+} from './gr-reviewer-updates-parser';
+import {parseDate} from '../../../utils/date-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import {appContext} from '../../../services/app-context';
+import {
+  getParentIndex,
+  isMergeParent,
+  patchNumEquals,
+} from '../../../utils/patch-set-util';
+import {
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../../utils/change-util';
+import {assertNever, hasOwnProperty} from '../../../utils/common-util';
+import {customElement, property} from '@polymer/decorators';
+import {AuthRequestInit, AuthService} from '../../../services/gr-auth/gr-auth';
+import {
+  AccountCapabilityInfo,
+  AccountDetailInfo,
+  AccountExternalIdInfo,
+  AccountId,
+  AccountInfo,
+  AssigneeInput,
+  Base64File,
+  Base64FileContent,
+  Base64ImageFile,
+  BranchInfo,
+  BranchName,
+  ChangeId,
+  ChangeInfo,
+  ChangeMessageId,
+  CommentInfo,
+  CommentInput,
+  CommitId,
+  CommitInfo,
+  ConfigInfo,
+  ConfigInput,
+  DashboardId,
+  DashboardInfo,
+  DeleteDraftCommentsInput,
+  DiffInfo,
+  DiffPreferenceInput,
+  DiffPreferencesInfo,
+  EditPatchSetNum,
+  EditPreferencesInfo,
+  EncodedGroupId,
+  GitRef,
+  GpgKeyId,
+  GroupId,
+  GroupInfo,
+  GroupInput,
+  GroupOptionsInput,
+  HashtagsInput,
+  ImagesForDiff,
+  NameToProjectInfoMap,
+  ParentPatchSetNum,
+  ParsedJSON,
+  PatchRange,
+  PatchSetNum,
+  PathToCommentsInfoMap,
+  PathToRobotCommentsInfoMap,
+  PreferencesInfo,
+  PreferencesInput,
+  ProjectAccessInfoMap,
+  ProjectAccessInput,
+  ProjectInfo,
+  ProjectInput,
+  ProjectWatchInfo,
+  RepoName,
+  ReviewInput,
+  ServerInfo,
+  SshKeyInfo,
+  UrlEncodedCommentId,
+  EditInfo,
+  FileNameToFileInfoMap,
+  SuggestedReviewerInfo,
+  GroupNameToGroupInfoMap,
+  GroupAuditEventInfo,
+  RequestPayload,
+  Password,
+  ContributorAgreementInput,
+  ContributorAgreementInfo,
+  BranchInput,
+  IncludedInInfo,
+  TagInput,
+  PluginInfo,
+  GpgKeyInfo,
+  GpgKeysInput,
+  DocResult,
+  EmailInfo,
+  ProjectAccessInfo,
+  CapabilityInfoMap,
+  ProjectInfoWithName,
+  TagInfo,
+  RelatedChangesInfo,
+  SubmittedTogetherInfo,
+  NumericChangeId,
+  EmailAddress,
+  FixId,
+  FilePathToDiffInfoMap,
+  ChangeViewChangeInfo,
+  BlameInfo,
+  ActionNameToActionInfoMap,
+  RevisionId,
+  GroupName,
+  Hashtag,
+  TopMenuEntryInfo,
+  MergeableInfo,
+} from '../../../types/common';
+import {
+  CancelConditionCallback,
+  ErrorCallback,
+  RestApiService,
+  GetDiffCommentsOutput,
+  GetDiffRobotCommentsOutput,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  CommentSide,
+  DiffViewMode,
+  HttpMethod,
+  IgnoreWhitespaceType,
+  ReviewerState,
+} from '../../../constants/constants';
+
+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 Requests = {
+  SEND_DIFF_DRAFT: 'sendDiffDraft',
+};
+
+const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
+  'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
+const HEADER_REPORTING_BLOCK_REGEX = /^set-cookie$/i;
+
+const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
+const ANONYMIZED_REVISION_BASE_URL =
+  ANONYMIZED_CHANGE_BASE_URL + '/revisions/*';
+
+let siteBasedCache = new SiteBasedCache(); // Shared across instances.
+let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances.
+let pendingRequest: {[promiseName: string]: Array<Promise<unknown>>} = {}; // Shared across instances.
+let grEtagDecorator = new GrEtagDecorator(); // Shared across instances.
+let projectLookup: {[changeNum: string]: RepoName} = {}; // Shared across instances.
+
+interface FetchChangeJSON {
+  reportEndpointAsIs?: boolean;
+  endpoint: string;
+  anonymizedEndpoint?: string;
+  revision?: RevisionId;
+  changeNum: NumericChangeId;
+  errFn?: ErrorCallback;
+  params?: FetchParams;
+  fetchOptions?: AuthRequestInit;
+  // TODO(TS): The following properties are not used, however some methods
+  // set them to true. They should be either changed to reportEndpointAsIs: true
+  // or deleted. This should be done carefully case by case.
+  reportEndpointAsId?: true;
+}
+
+interface SendChangeRequestBase {
+  patchNum?: PatchSetNum;
+  reportEndpointAsIs?: boolean;
+  endpoint: string;
+  anonymizedEndpoint?: string;
+  changeNum: NumericChangeId;
+  method: HttpMethod | undefined;
+  errFn?: ErrorCallback;
+  headers?: Record<string, string>;
+  contentType?: string;
+  body?: string | object;
+
+  // TODO(TS): The following properties are not used, however some methods
+  // set them to true. They should be either changed to reportEndpointAsIs: true
+  // or deleted. This should be done carefully case by case.
+  reportUrlAsIs?: true;
+  reportEndpointAsId?: true;
+}
+
+interface SendRawChangeRequest extends SendChangeRequestBase {
+  parseResponse?: false | null;
+}
+
+interface SendJSONChangeRequest extends SendChangeRequestBase {
+  parseResponse: true;
+}
+
+interface QueryChangesParams {
+  [paramName: string]: string | undefined | number | string[];
+  O?: string; // options
+  S: number; // start
+  n?: number; // changes per page
+  q?: string | string[]; // query/queries
+}
+
+interface QueryAccountsParams {
+  [paramName: string]: string | undefined | null | number;
+  suggest: null;
+  q: string;
+  n?: number;
+}
+
+interface QueryGroupsParams {
+  [paramName: string]: string | undefined | null | number;
+  s: string;
+  n?: number;
+  p?: string;
+}
+
+interface QuerySuggestedReviewersParams {
+  [paramName: string]: string | undefined | null | number;
+  n: number;
+  q?: string;
+  'reviewer-state': ReviewerState;
+}
+
+interface GetDiffParams {
+  [paramName: string]: string | undefined | null | number | boolean;
+  context?: number | 'ALL';
+  intraline?: boolean | null;
+  whitespace?: IgnoreWhitespaceType;
+  parent?: number;
+  base?: PatchSetNum;
+}
+
+type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
+
+export function _testOnlyResetGrRestApiSharedObjects() {
+  // TODO(TS): The commented code below didn't do anything.
+  // It is impossible to reject an existing promise. Should be rewritten in a
+  // different way
+  // const fetchPromisesCacheData = fetchPromisesCache.testOnlyGetData();
+  // for (const key in fetchPromisesCacheData) {
+  //   if (hasOwnProperty(fetchPromisesCacheData, key)) {
+  //     // reject already fulfilled promise does nothing
+  //     fetchPromisesCacheData[key]!.reject();
+  //   }
+  // }
+  //
+  // for (const key in pendingRequest) {
+  //   if (!hasOwnProperty(pendingRequest, key)) {
+  //     continue;
+  //   }
+  //   for (const req of pendingRequest[key]) {
+  //     // reject already fulfilled promise does nothing
+  //     req.reject();
+  //   }
+  // }
+
+  siteBasedCache = new SiteBasedCache();
+  fetchPromisesCache = new FetchPromisesCache();
+  pendingRequest = {};
+  grEtagDecorator = new GrEtagDecorator();
+  projectLookup = {};
+  appContext.authService.clearCache();
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-rest-api-interface': GrRestApiInterface;
+  }
+}
+
+@customElement('gr-rest-api-interface')
+export class GrRestApiInterface
+  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  implements RestApiService {
+  readonly JSON_PREFIX = JSON_PREFIX;
+  /**
+   * Fired when an server error occurs.
+   *
+   * @event server-error
+   */
+
+  /**
+   * Fired when a network error occurs.
+   *
+   * @event network-error
+   */
+
+  /**
+   * Fired after an RPC completes.
+   *
+   * @event rpc-log
+   */
+
+  @property({type: Object})
+  readonly _cache = siteBasedCache; // Shared across instances.
+
+  @property({type: Object})
+  readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
+
+  @property({type: Object})
+  readonly _pendingRequests = pendingRequest; // Shared across instances.
+
+  @property({type: Object})
+  readonly _etags = grEtagDecorator; // Shared across instances.
+
+  @property({type: Object})
+  readonly _projectLookup = projectLookup; // Shared across instances.
+
+  // The value is set in created, before any other actions
+  private authService: AuthService;
+
+  // The value is set in created, before any other actions
+  private readonly _restApiHelper: GrRestApiHelper;
+
+  constructor() {
+    super();
+    this.authService = appContext.authService;
+    this._restApiHelper = new GrRestApiHelper(
+      this._cache,
+      this.authService,
+      this._sharedFetchPromises,
+      this
+    );
+  }
+
+  _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
+    // Cache is shared across instances
+    return this._restApiHelper.fetchCacheURL(req);
+  }
+
+  getResponseObject(response: Response): Promise<ParsedJSON> {
+    return this._restApiHelper.getResponseObject(response);
+  }
+
+  getConfig(noCache?: boolean): Promise<ServerInfo | undefined> {
+    if (!noCache) {
+      return this._fetchSharedCacheURL({
+        url: '/config/server/info',
+        reportUrlAsIs: true,
+      }) as Promise<ServerInfo | undefined>;
+    }
+
+    return this._restApiHelper.fetchJSON({
+      url: '/config/server/info',
+      reportUrlAsIs: true,
+    }) as Promise<ServerInfo | undefined>;
+  }
+
+  getRepo(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<ProjectInfo | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/projects/' + encodeURIComponent(repo),
+      errFn,
+      anonymizedUrl: '/projects/*',
+    }) as Promise<ProjectInfo | undefined>;
+  }
+
+  getProjectConfig(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<ConfigInfo | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/projects/' + encodeURIComponent(repo) + '/config',
+      errFn,
+      anonymizedUrl: '/projects/*/config',
+    }) as Promise<ConfigInfo | undefined>;
+  }
+
+  getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/access/?project=' + encodeURIComponent(repo),
+      anonymizedUrl: '/access/?project=*',
+    }) as Promise<ProjectAccessInfoMap | undefined>;
+  }
+
+  getRepoDashboards(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<DashboardInfo[] | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
+      errFn,
+      anonymizedUrl: '/projects/*/dashboards?inherited',
+    }) as Promise<DashboardInfo[] | undefined>;
+  }
+
+  saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
+
+  saveRepoConfig(
+    repo: RepoName,
+    config: ConfigInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  saveRepoConfig(
+    repo: RepoName,
+    config: ConfigInput,
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined> {
+    // 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: HttpMethod.PUT,
+      url,
+      body: config,
+      errFn,
+      anonymizedUrl: '/projects/*/config',
+    });
+  }
+
+  runRepoGC(repo: RepoName): Promise<Response>;
+
+  runRepoGC(
+    repo: RepoName,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  runRepoGC(repo: RepoName, errFn?: ErrorCallback) {
+    if (!repo) {
+      // TODO(TS): fix return value
+      return '';
+    }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: `/projects/${encodeName}/gc`,
+      body: '',
+      errFn,
+      anonymizedUrl: '/projects/*/gc',
+    });
+  }
+
+  createRepo(config: ProjectInput & {name: RepoName}): Promise<Response>;
+
+  createRepo(
+    config: ProjectInput & {name: RepoName},
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  createRepo(config: ProjectInput, errFn?: ErrorCallback) {
+    if (!config.name) {
+      // TODO(TS): Fix return value
+      return '';
+    }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(config.name);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/projects/${encodeName}`,
+      body: config,
+      errFn,
+      anonymizedUrl: '/projects/*',
+    });
+  }
+
+  createGroup(config: GroupInput & {name: string}): Promise<Response>;
+
+  createGroup(
+    config: GroupInput & {name: string},
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  createGroup(config: GroupInput, errFn?: ErrorCallback) {
+    if (!config.name) {
+      // TODO(TS): Fix return value
+      return '';
+    }
+    const encodeName = encodeURIComponent(config.name);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeName}`,
+      body: config,
+      errFn,
+      anonymizedUrl: '/groups/*',
+    });
+  }
+
+  getGroupConfig(
+    group: GroupId | GroupName,
+    errFn?: ErrorCallback
+  ): Promise<GroupInfo | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeURIComponent(group)}/detail`,
+      errFn,
+      anonymizedUrl: '/groups/*/detail',
+    }) as Promise<GroupInfo | undefined>;
+  }
+
+  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
+
+  deleteRepoBranches(
+    repo: RepoName,
+    ref: GitRef,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  deleteRepoBranches(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) {
+    if (!repo || !ref) {
+      // TODO(TS): fix return value
+      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: HttpMethod.DELETE,
+      url: `/projects/${encodeName}/branches/${encodeRef}`,
+      body: '',
+      errFn,
+      anonymizedUrl: '/projects/*/branches/*',
+    });
+  }
+
+  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
+
+  deleteRepoTags(
+    repo: RepoName,
+    ref: GitRef,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  deleteRepoTags(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) {
+    if (!repo || !ref) {
+      // TODO(TS): fix return type
+      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: HttpMethod.DELETE,
+      url: `/projects/${encodeName}/tags/${encodeRef}`,
+      body: '',
+      errFn,
+      anonymizedUrl: '/projects/*/tags/*',
+    });
+  }
+
+  createRepoBranch(
+    name: RepoName,
+    branch: BranchName,
+    revision: BranchInput
+  ): Promise<Response>;
+
+  createRepoBranch(
+    name: RepoName,
+    branch: BranchName,
+    revision: BranchInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  createRepoBranch(
+    name: RepoName,
+    branch: BranchName,
+    revision: BranchInput,
+    errFn?: ErrorCallback
+  ) {
+    if (!name || !branch || !revision) {
+      // TODO(TS) fix return type
+      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: HttpMethod.PUT,
+      url: `/projects/${encodeName}/branches/${encodeBranch}`,
+      body: revision,
+      errFn,
+      anonymizedUrl: '/projects/*/branches/*',
+    });
+  }
+
+  createRepoTag(
+    name: RepoName,
+    tag: string,
+    revision: TagInput
+  ): Promise<Response>;
+
+  createRepoTag(
+    name: RepoName,
+    tag: string,
+    revision: TagInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  createRepoTag(
+    name: RepoName,
+    tag: string,
+    revision: TagInput,
+    errFn?: ErrorCallback
+  ) {
+    if (!name || !tag || !revision) {
+      // TODO(TS): Fix return value
+      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: HttpMethod.PUT,
+      url: `/projects/${encodeName}/tags/${encodeTag}`,
+      body: revision,
+      errFn,
+      anonymizedUrl: '/projects/*/tags/*',
+    });
+  }
+
+  getIsGroupOwner(groupName: GroupName): Promise<boolean> {
+    const encodeName = encodeURIComponent(groupName);
+    const req = {
+      url: `/groups/?owned&g=${encodeName}`,
+      anonymizedUrl: '/groups/owned&g=*',
+    };
+    return this._fetchSharedCacheURL(req).then(configs =>
+      hasOwnProperty(configs, groupName)
+    );
+  }
+
+  getGroupMembers(
+    groupName: GroupId | GroupName,
+    errFn?: ErrorCallback
+  ): Promise<AccountInfo[] | undefined> {
+    const encodeName = encodeURIComponent(groupName);
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeName}/members/`,
+      errFn,
+      anonymizedUrl: '/groups/*/members',
+    }) as Promise<AccountInfo[] | undefined>;
+  }
+
+  getIncludedGroup(
+    groupName: GroupId | GroupName
+  ): Promise<GroupInfo[] | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeURIComponent(groupName)}/groups/`,
+      anonymizedUrl: '/groups/*/groups',
+    }) as Promise<GroupInfo[] | undefined>;
+  }
+
+  saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeId}/name`,
+      body: {name},
+      anonymizedUrl: '/groups/*/name',
+    });
+  }
+
+  saveGroupOwner(
+    groupId: GroupId | GroupName,
+    ownerId: string
+  ): Promise<Response> {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeId}/owner`,
+      body: {owner: ownerId},
+      anonymizedUrl: '/groups/*/owner',
+    });
+  }
+
+  saveGroupDescription(
+    groupId: GroupId | GroupName,
+    description: string
+  ): Promise<Response> {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeId}/description`,
+      body: {description},
+      anonymizedUrl: '/groups/*/description',
+    });
+  }
+
+  saveGroupOptions(
+    groupId: GroupId | GroupName,
+    options: GroupOptionsInput
+  ): Promise<Response> {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeId}/options`,
+      body: options,
+      anonymizedUrl: '/groups/*/options',
+    });
+  }
+
+  getGroupAuditLog(
+    group: EncodedGroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupAuditEventInfo[] | undefined> {
+    return this._fetchSharedCacheURL({
+      url: `/groups/${group}/log.audit`,
+      errFn,
+      anonymizedUrl: '/groups/*/log.audit',
+    }) as Promise<GroupAuditEventInfo[] | undefined>;
+  }
+
+  saveGroupMember(
+    groupName: GroupId | GroupName,
+    groupMember: AccountId
+  ): Promise<AccountInfo> {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeMember = encodeURIComponent(`${groupMember}`);
+    return (this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeName}/members/${encodeMember}`,
+      parseResponse: true,
+      anonymizedUrl: '/groups/*/members/*',
+    }) as unknown) as Promise<AccountInfo>;
+  }
+
+  saveIncludedGroup(
+    groupName: GroupId | GroupName,
+    includedGroup: GroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupInfo | undefined> {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeIncludedGroup = encodeURIComponent(includedGroup);
+    const req = {
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+      errFn,
+      anonymizedUrl: '/groups/*/groups/*',
+    };
+    return this._restApiHelper.send(req).then(response => {
+      if (response?.ok) {
+        return (this.getResponseObject(response) as unknown) as Promise<
+          GroupInfo
+        >;
+      }
+      return undefined;
+    });
+  }
+
+  deleteGroupMember(
+    groupName: GroupId | GroupName,
+    groupMember: AccountId
+  ): Promise<Response> {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeMember = encodeURIComponent(`${groupMember}`);
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: `/groups/${encodeName}/members/${encodeMember}`,
+      anonymizedUrl: '/groups/*/members/*',
+    });
+  }
+
+  deleteIncludedGroup(
+    groupName: GroupId,
+    includedGroup: GroupId | GroupName
+  ): Promise<Response> {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeIncludedGroup = encodeURIComponent(includedGroup);
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+      anonymizedUrl: '/groups/*/groups/*',
+    });
+  }
+
+  getVersion(): Promise<string | undefined> {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/version',
+      reportUrlAsIs: true,
+    }) as Promise<string | undefined>;
+  }
+
+  getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return this._fetchSharedCacheURL({
+          url: '/accounts/self/preferences.diff',
+          reportUrlAsIs: true,
+        }) as Promise<DiffPreferencesInfo | undefined>;
+      }
+      const anonymousResult: DiffPreferencesInfo = {
+        auto_hide_diff_table_header: true,
+        context: 10,
+        cursor_blink_rate: 0,
+        font_size: 12,
+        ignore_whitespace: IgnoreWhitespaceType.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',
+      };
+      // 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(anonymousResult);
+    });
+  }
+
+  getEditPreferences(): Promise<EditPreferencesInfo | undefined> {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return this._fetchSharedCacheURL({
+          url: '/accounts/self/preferences.edit',
+          reportUrlAsIs: true,
+        }) as Promise<EditPreferencesInfo | undefined>;
+      }
+      const result: EditPreferencesInfo = {
+        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',
+      };
+      // These defaults should match the defaults in
+      // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+      return Promise.resolve(result);
+    });
+  }
+
+  savePreferences(prefs: PreferencesInput): Promise<Response>;
+
+  savePreferences(
+    prefs: PreferencesInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  savePreferences(prefs: PreferencesInput, errFn?: ErrorCallback) {
+    // Note (Issue 5142): normalize the download scheme with lower case before
+    // saving.
+    if (prefs.download_scheme) {
+      prefs.download_scheme = prefs.download_scheme.toLowerCase();
+    }
+
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: '/accounts/self/preferences',
+      body: prefs,
+      errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response>;
+
+  saveDiffPreferences(
+    prefs: DiffPreferenceInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  saveDiffPreferences(prefs: DiffPreferenceInput, errFn?: ErrorCallback) {
+    // Invalidate the cache.
+    this._cache.delete('/accounts/self/preferences.diff');
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: '/accounts/self/preferences.diff',
+      body: prefs,
+      errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>;
+
+  saveEditPreferences(
+    prefs: EditPreferencesInfo,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  saveEditPreferences(prefs: EditPreferencesInfo, errFn?: ErrorCallback) {
+    // Invalidate the cache.
+    this._cache.delete('/accounts/self/preferences.edit');
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: '/accounts/self/preferences.edit',
+      body: prefs,
+      errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  getAccount(): Promise<AccountDetailInfo | undefined> {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/detail',
+      reportUrlAsIs: true,
+      errFn: resp => {
+        if (!resp || resp.status === 403) {
+          this._cache.delete('/accounts/self/detail');
+        }
+      },
+    }) as Promise<AccountDetailInfo | undefined>;
+  }
+
+  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');
+        }
+      },
+    }) as Promise<string | undefined>;
+  }
+
+  getExternalIds() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/external.ids',
+      reportUrlAsIs: true,
+    }) as Promise<AccountExternalIdInfo[] | undefined>;
+  }
+
+  deleteAccountIdentity(id: string[]) {
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/accounts/self/external.ids:delete',
+      body: id,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as Promise<unknown>;
+  }
+
+  getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: `/accounts/${encodeURIComponent(userId)}/detail`,
+      anonymizedUrl: '/accounts/*/detail',
+    }) as Promise<AccountDetailInfo | undefined>;
+  }
+
+  getAccountEmails() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/emails',
+      reportUrlAsIs: true,
+    }) as Promise<EmailInfo[] | undefined>;
+  }
+
+  addAccountEmail(email: string): Promise<Response>;
+
+  addAccountEmail(
+    email: string,
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  addAccountEmail(email: string, errFn?: ErrorCallback) {
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: '/accounts/self/emails/' + encodeURIComponent(email),
+      errFn,
+      anonymizedUrl: '/account/self/emails/*',
+    });
+  }
+
+  deleteAccountEmail(email: string): Promise<Response>;
+
+  deleteAccountEmail(
+    email: string,
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  deleteAccountEmail(email: string, errFn?: ErrorCallback) {
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: '/accounts/self/emails/' + encodeURIComponent(email),
+      errFn,
+      anonymizedUrl: '/accounts/self/email/*',
+    });
+  }
+
+  setPreferredAccountEmail(
+    email: string,
+    errFn?: ErrorCallback
+  ): Promise<void> {
+    // TODO(TS): add correct error handling
+    const encodedEmail = encodeURIComponent(email);
+    const req = {
+      method: HttpMethod.PUT,
+      url: `/accounts/self/emails/${encodedEmail}/preferred`,
+      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);
+      }
+    });
+  }
+
+  _updateCachedAccount(obj: Partial<AccountDetailInfo>): void {
+    // 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', {...cachedAccount, ...obj});
+    }
+  }
+
+  setAccountName(name: string, errFn?: ErrorCallback): Promise<void> {
+    // TODO(TS): add correct error handling
+    const req: SendJSONRequest = {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/name',
+      body: {name},
+      errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then(newName =>
+        this._updateCachedAccount({name: (newName as unknown) as string})
+      );
+  }
+
+  setAccountUsername(username: string, errFn?: ErrorCallback): Promise<void> {
+    // TODO(TS): add correct error handling
+    const req: SendJSONRequest = {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/username',
+      body: {username},
+      errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then(newName =>
+        this._updateCachedAccount({username: (newName as unknown) as string})
+      );
+  }
+
+  setAccountDisplayName(
+    displayName: string,
+    errFn?: ErrorCallback
+  ): Promise<void> {
+    // TODO(TS): add correct error handling
+    const req: SendJSONRequest = {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/displayname',
+      body: {display_name: displayName},
+      errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req).then(newName =>
+      this._updateCachedAccount({
+        display_name: (newName as unknown) as string,
+      })
+    );
+  }
+
+  setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void> {
+    // TODO(TS): add correct error handling
+    const req: SendJSONRequest = {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/status',
+      body: {status},
+      errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then(newStatus =>
+        this._updateCachedAccount({status: (newStatus as unknown) as string})
+      );
+  }
+
+  getAccountStatus(userId: AccountId) {
+    return this._restApiHelper.fetchJSON({
+      url: `/accounts/${encodeURIComponent(userId)}/status`,
+      anonymizedUrl: '/accounts/*/status',
+    }) as Promise<string | undefined>;
+  }
+
+  // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#list-groups
+  getAccountGroups() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/groups',
+      reportUrlAsIs: true,
+    }) as Promise<GroupInfo[] | undefined>;
+  }
+
+  getAccountAgreements() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/agreements',
+      reportUrlAsIs: true,
+    }) as Promise<ContributorAgreementInfo[] | undefined>;
+  }
+
+  saveAccountAgreement(name: ContributorAgreementInput): Promise<Response> {
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: '/accounts/self/agreements',
+      body: name,
+      reportUrlAsIs: true,
+    });
+  }
+
+  getAccountCapabilities(
+    params?: string[]
+  ): Promise<AccountCapabilityInfo | undefined> {
+    let queryString = '';
+    if (params) {
+      queryString =
+        '?q=' + params.map(param => encodeURIComponent(param)).join('&q=');
+    }
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/capabilities' + queryString,
+      anonymizedUrl: '/accounts/self/capabilities?q=*',
+    }) as Promise<AccountCapabilityInfo | undefined>;
+  }
+
+  getLoggedIn() {
+    return this.authService.authCheck();
+  }
+
+  getIsAdmin() {
+    return this.getLoggedIn()
+      .then(isLoggedIn => {
+        if (isLoggedIn) {
+          return this.getAccountCapabilities();
+        } else {
+          return;
+        }
+      })
+      .then(
+        (capabilities: AccountCapabilityInfo | undefined) =>
+          capabilities && capabilities.administrateServer
+      );
+  }
+
+  getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/preferences',
+      reportUrlAsIs: true,
+    }) as Promise<PreferencesInfo | undefined>;
+  }
+
+  getPreferences(): Promise<PreferencesInfo | undefined> {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
+        return this._fetchSharedCacheURL(req).then(res => {
+          if (!res) {
+            return res;
+          }
+          const prefInfo = (res as unknown) as PreferencesInfo;
+          if (this._isNarrowScreen()) {
+            // Note that this can be problematic, because the diff will stay
+            // unified even after increasing the window width.
+            prefInfo.default_diff_view = DiffViewMode.UNIFIED;
+          } else {
+            prefInfo.default_diff_view = prefInfo.diff_view;
+          }
+          return prefInfo;
+        });
+      }
+
+      // TODO(TS): Many properties are omitted here, but they are required.
+      // Add default values for missed properties
+      const anonymousPrefs = {
+        changes_per_page: 25,
+        default_diff_view: this._isNarrowScreen()
+          ? DiffViewMode.UNIFIED
+          : DiffViewMode.SIDE_BY_SIDE,
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+        size_bar_in_change_table: true,
+      } as PreferencesInfo;
+
+      return anonymousPrefs;
+    });
+  }
+
+  getWatchedProjects() {
+    return (this._fetchSharedCacheURL({
+      url: '/accounts/self/watched.projects',
+      reportUrlAsIs: true,
+    }) as unknown) as Promise<ProjectWatchInfo[] | undefined>;
+  }
+
+  saveWatchedProjects(
+    projects: ProjectWatchInfo[],
+    errFn?: ErrorCallback
+  ): Promise<ProjectWatchInfo[]> {
+    return (this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/accounts/self/watched.projects',
+      body: projects,
+      errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as unknown) as Promise<ProjectWatchInfo[]>;
+  }
+
+  deleteWatchedProjects(
+    projects: ProjectWatchInfo[]
+  ): Promise<Response | undefined>;
+
+  deleteWatchedProjects(
+    projects: ProjectWatchInfo[],
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  deleteWatchedProjects(projects: ProjectWatchInfo[], errFn?: ErrorCallback) {
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/accounts/self/watched.projects:delete',
+      body: projects,
+      errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  _isNarrowScreen() {
+    return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
+  }
+
+  getChanges(
+    changesPerPage?: number,
+    query?: string,
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[] | undefined>;
+
+  getChanges(
+    changesPerPage?: number,
+    query?: string[],
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[][] | undefined>;
+
+  /**
+   * @return 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(
+    changesPerPage?: number,
+    query?: string | string[],
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined> {
+    return this.getConfig(false)
+      .then(config => {
+        // TODO(TS): config can be null/undefined. Need some checks
+        options = options || this._getChangesOptionsHex(config);
+        // Issue 4524: respect legacy token with max sortkey.
+        if (offset === 'n,z') {
+          offset = 0;
+        }
+        const params: QueryChangesParams = {
+          O: options,
+          S: offset || 0,
+        };
+        if (changesPerPage) {
+          params.n = changesPerPage;
+        }
+        if (query && query.length > 0) {
+          params.q = query;
+        }
+        return {
+          url: '/changes/',
+          params,
+          reportUrlAsIs: true,
+        };
+      })
+      .then(
+        req =>
+          this._restApiHelper.fetchJSON(req, true) as Promise<
+            ChangeInfo[] | ChangeInfo[][] | undefined
+          >
+      )
+      .then(response => {
+        if (!response) {
+          return;
+        }
+        const iterateOverChanges = (arr: ChangeInfo[]) => {
+          for (const change of arr) {
+            this._maybeInsertInLookup(change);
+          }
+        };
+        // Response may be an array of changes OR an array of arrays of
+        // changes.
+        if (query instanceof Array) {
+          // Normalize the response to look like a multi-query response
+          // when there is only one query.
+          const responseArray: Array<ChangeInfo[]> =
+            query.length === 1
+              ? [response as ChangeInfo[]]
+              : (response as ChangeInfo[][]);
+          for (const arr of responseArray) {
+            iterateOverChanges(arr);
+          }
+          return responseArray;
+        } else {
+          iterateOverChanges(response as ChangeInfo[]);
+          return response as ChangeInfo[];
+        }
+      });
+  }
+
+  /**
+   * Inserts a change into _projectLookup iff it has a valid structure.
+   */
+  _maybeInsertInLookup(change: ChangeInfo): void {
+    if (change?.project && change._number) {
+      this.setInProjectLookup(change._number, change.project);
+    }
+  }
+
+  getChangeActionURL(
+    changeNum: NumericChangeId,
+    revisionId: RevisionId | undefined,
+    endpoint: string
+  ): Promise<string> {
+    return this._changeBaseURL(changeNum, revisionId).then(
+      url => url + endpoint
+    );
+  }
+
+  getChangeDetail(
+    changeNum: NumericChangeId,
+    errFn?: ErrorCallback,
+    cancelCondition?: CancelConditionCallback
+  ): Promise<ParsedChangeInfo | null | undefined> {
+    return this.getConfig(false).then(config => {
+      const optionsHex = this._getChangeOptionsHex(config);
+      return this._getChangeDetail(
+        changeNum,
+        optionsHex,
+        errFn,
+        cancelCondition
+      ).then(detail =>
+        // detail has ChangeViewChangeInfo type because the optionsHex always
+        // includes ALL_REVISIONS flag.
+        GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo)
+      );
+    });
+  }
+
+  _getChangesOptionsHex(config?: ServerInfo) {
+    if (
+      window.DEFAULT_DETAIL_HEXES &&
+      window.DEFAULT_DETAIL_HEXES.dashboardPage
+    ) {
+      return window.DEFAULT_DETAIL_HEXES.dashboardPage;
+    }
+    const options = [
+      ListChangesOption.LABELS,
+      ListChangesOption.DETAILED_ACCOUNTS,
+    ];
+    if (!config?.change?.enable_attention_set) {
+      options.push(ListChangesOption.REVIEWED);
+    }
+
+    return listChangesOptionsToHex(...options);
+  }
+
+  _getChangeOptionsHex(config?: ServerInfo) {
+    if (
+      window.DEFAULT_DETAIL_HEXES &&
+      window.DEFAULT_DETAIL_HEXES.changePage &&
+      (!config || !(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 = [
+      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,
+    ];
+    if (config?.receive?.enable_signed_push) {
+      options.push(ListChangesOption.PUSH_CERTIFICATES);
+    }
+    return listChangesOptionsToHex(...options);
+  }
+
+  getDiffChangeDetail(
+    changeNum: NumericChangeId,
+    errFn?: ErrorCallback,
+    cancelCondition?: CancelConditionCallback
+  ) {
+    let optionsHex = '';
+    if (window.DEFAULT_DETAIL_HEXES?.diffPage) {
+      optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
+    } else {
+      optionsHex = listChangesOptionsToHex(
+        ListChangesOption.ALL_COMMITS,
+        ListChangesOption.ALL_REVISIONS,
+        ListChangesOption.SKIP_DIFFSTAT
+      );
+    }
+    return this._getChangeDetail(changeNum, optionsHex, errFn, cancelCondition);
+  }
+
+  /**
+   * @param optionsHex list changes options in hex
+   */
+  _getChangeDetail(
+    changeNum: NumericChangeId,
+    optionsHex: string,
+    errFn?: ErrorCallback,
+    cancelCondition?: CancelConditionCallback
+  ): Promise<ChangeInfo | undefined | null> {
+    return this.getChangeActionURL(changeNum, undefined, '/detail').then(
+      url => {
+        const params: FetchParams = {O: optionsHex};
+        const urlWithParams = this._restApiHelper.urlWithParams(url, params);
+        const req: FetchJSONRequest = {
+          url,
+          errFn,
+          cancelCondition,
+          params,
+          fetchOptions: this._etags.getOptions(urlWithParams),
+          anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
+        };
+        return this._restApiHelper.fetchRawJSON(req).then(response => {
+          if (response?.status === 304) {
+            return (this._restApiHelper.parsePrefixedJSON(
+              // urlWithParams already cached
+              this._etags.getCachedPayload(urlWithParams)!
+            ) as unknown) as ChangeInfo;
+          }
+
+          if (response && !response.ok) {
+            if (errFn) {
+              errFn.call(null, response);
+            } else {
+              this.dispatchEvent(
+                new CustomEvent('server-error', {
+                  detail: {request: req, response},
+                  composed: true,
+                  bubbles: true,
+                })
+              );
+            }
+            return undefined;
+          }
+
+          if (!response) {
+            return Promise.resolve(null);
+          }
+
+          return this._restApiHelper
+            .readResponsePayload(response)
+            .then(payload => {
+              if (!payload) {
+                return null;
+              }
+              this._etags.collect(urlWithParams, response, payload.raw);
+              // TODO(TS): Why it is always change info?
+              this._maybeInsertInLookup(
+                (payload.parsed as unknown) as ChangeInfo
+              );
+
+              return (payload.parsed as unknown) as ChangeInfo;
+            });
+        });
+      }
+    );
+  }
+
+  getChangeCommitInfo(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/commit?links',
+      revision: patchNum,
+      reportEndpointAsIs: true,
+    }) as Promise<CommitInfo | undefined>;
+  }
+
+  getChangeFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined> {
+    let params = undefined;
+    if (isMergeParent(patchRange.basePatchNum)) {
+      params = {parent: getParentIndex(patchRange.basePatchNum)};
+    } else if (!patchNumEquals(patchRange.basePatchNum, ParentPatchSetNum)) {
+      params = {base: patchRange.basePatchNum};
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/files',
+      revision: patchRange.patchNum,
+      params,
+      reportEndpointAsIs: true,
+    }) as Promise<FileNameToFileInfoMap | undefined>;
+  }
+
+  // TODO(TS): The output type is unclear
+  getChangeEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<{files: FileNameToFileInfoMap} | undefined> {
+    let endpoint = '/edit?list';
+    let anonymizedEndpoint = endpoint;
+    if (patchRange.basePatchNum !== ParentPatchSetNum) {
+      endpoint += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`);
+      anonymizedEndpoint += '&base=*';
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint,
+      anonymizedEndpoint,
+    }) as Promise<{files: FileNameToFileInfoMap} | undefined>;
+  }
+
+  queryChangeFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    query: string
+  ) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/files?q=${encodeURIComponent(query)}`,
+      revision: patchNum,
+      anonymizedEndpoint: '/files?q=*',
+    }) as Promise<string[] | undefined>;
+  }
+
+  getChangeOrEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined> {
+    if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) {
+      return this.getChangeEditFiles(changeNum, patchRange).then(
+        res => res && res.files
+      );
+    }
+    return this.getChangeFiles(changeNum, patchRange);
+  }
+
+  getChangeRevisionActions(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<ActionNameToActionInfoMap | undefined> {
+    const req: FetchChangeJSON = {
+      changeNum,
+      endpoint: '/actions',
+      revision: patchNum,
+      reportEndpointAsIs: true,
+    };
+    return this._getChangeURLAndFetch(req) as Promise<
+      ActionNameToActionInfoMap | undefined
+    >;
+  }
+
+  getChangeSuggestedReviewers(
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeSuggestedGroup(
+      ReviewerState.REVIEWER,
+      changeNum,
+      inputVal,
+      errFn
+    );
+  }
+
+  getChangeSuggestedCCs(
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeSuggestedGroup(
+      ReviewerState.CC,
+      changeNum,
+      inputVal,
+      errFn
+    );
+  }
+
+  _getChangeSuggestedGroup(
+    reviewerState: ReviewerState,
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ): Promise<SuggestedReviewerInfo[] | undefined> {
+    // More suggestions may obscure content underneath in the reply dialog,
+    // see issue 10793.
+    const params: QuerySuggestedReviewersParams = {
+      n: 6,
+      'reviewer-state': reviewerState,
+    };
+    if (inputVal) {
+      params.q = inputVal;
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/suggest_reviewers',
+      errFn,
+      params,
+      reportEndpointAsIs: true,
+    }) as Promise<SuggestedReviewerInfo[] | undefined>;
+  }
+
+  getChangeIncludedIn(
+    changeNum: NumericChangeId
+  ): Promise<IncludedInInfo | undefined> {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/in',
+      reportEndpointAsIs: true,
+    }) as Promise<IncludedInInfo | undefined>;
+  }
+
+  _computeFilter(filter: string) {
+    if (filter?.startsWith('^')) {
+      filter = '&r=' + encodeURIComponent(filter);
+    } else if (filter) {
+      filter = '&m=' + encodeURIComponent(filter);
+    } else {
+      filter = '';
+    }
+    return filter;
+  }
+
+  _getGroupsUrl(filter: string, groupsPerPage: number, offset?: number) {
+    offset = offset || 0;
+
+    return (
+      `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
+      this._computeFilter(filter)
+    );
+  }
+
+  _getReposUrl(
+    filter: string | undefined,
+    reposPerPage: number,
+    offset?: number
+  ) {
+    const defaultFilter = 'state:active OR state:read-only';
+    const namePartDelimiters = /[@.\-\s/_]/g;
+    offset = 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;
+    }
+
+    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/?');
+  }
+
+  invalidateAccountsCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
+  }
+
+  invalidateAccountsDetailCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
+  }
+
+  getGroups(filter: string, groupsPerPage: number, offset?: number) {
+    const url = this._getGroupsUrl(filter, groupsPerPage, offset);
+
+    return this._fetchSharedCacheURL({
+      url,
+      anonymizedUrl: '/groups/?*',
+    }) as Promise<GroupNameToGroupInfoMap | undefined>;
+  }
+
+  getRepos(
+    filter: string | undefined,
+    reposPerPage: number,
+    offset?: number
+  ): Promise<ProjectInfoWithName[] | undefined> {
+    const url = this._getReposUrl(filter, reposPerPage, offset);
+
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url, // The url contains query,so the response is an array, not map
+      anonymizedUrl: '/projects/?*',
+    }) as Promise<ProjectInfoWithName[] | undefined>;
+  }
+
+  setRepoHead(repo: RepoName, ref: GitRef) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/projects/${encodeURIComponent(repo)}/HEAD`,
+      body: {ref},
+      anonymizedUrl: '/projects/*/HEAD',
+    });
+  }
+
+  getRepoBranches(
+    filter: string,
+    repo: RepoName,
+    reposBranchesPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<BranchInfo[] | undefined> {
+    offset = offset || 0;
+    const count = reposBranchesPerPage + 1;
+    filter = this._computeFilter(filter);
+    const encodedRepo = encodeURIComponent(repo);
+    const url = `/projects/${encodedRepo}/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,
+      anonymizedUrl: '/projects/*/branches?*',
+    }) as Promise<BranchInfo[] | undefined>;
+  }
+
+  getRepoTags(
+    filter: string,
+    repo: RepoName,
+    reposTagsPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ) {
+    offset = 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,
+      anonymizedUrl: '/projects/*/tags',
+    }) as unknown) as Promise<TagInfo[]>;
+  }
+
+  getPlugins(
+    filter: string,
+    pluginsPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<{[pluginName: string]: PluginInfo} | undefined> {
+    offset = 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,
+      anonymizedUrl: '/plugins/?all',
+    });
+  }
+
+  getRepoAccessRights(
+    repoName: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<ProjectAccessInfo | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url: `/projects/${encodeURIComponent(repoName)}/access`,
+      errFn,
+      anonymizedUrl: '/projects/*/access',
+    }) as Promise<ProjectAccessInfo | undefined>;
+  }
+
+  setRepoAccessRights(
+    repoName: RepoName,
+    repoInfo: ProjectAccessInput
+  ): Promise<Response> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: `/projects/${encodeURIComponent(repoName)}/access`,
+      body: repoInfo,
+      anonymizedUrl: '/projects/*/access',
+    });
+  }
+
+  setRepoAccessRightsForReview(
+    projectName: RepoName,
+    projectInfo: ProjectAccessInput
+  ): Promise<ChangeInfo> {
+    return (this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/projects/${encodeURIComponent(projectName)}/access:review`,
+      body: projectInfo,
+      parseResponse: true,
+      anonymizedUrl: '/projects/*/access:review',
+    }) as unknown) as Promise<ChangeInfo>;
+  }
+
+  getSuggestedGroups(
+    inputVal: string,
+    project?: RepoName,
+    n?: number,
+    errFn?: ErrorCallback
+  ): Promise<GroupNameToGroupInfoMap | undefined> {
+    const params: QueryGroupsParams = {s: inputVal};
+    if (n) {
+      params.n = n;
+    }
+    if (project) {
+      params.p = project;
+    }
+    return this._restApiHelper.fetchJSON({
+      url: '/groups/',
+      errFn,
+      params,
+      reportUrlAsIs: true,
+    }) as Promise<GroupNameToGroupInfoMap | undefined>;
+  }
+
+  getSuggestedProjects(
+    inputVal: string,
+    n?: number,
+    errFn?: ErrorCallback
+  ): Promise<NameToProjectInfoMap | undefined> {
+    const params = {
+      m: inputVal,
+      n: MAX_PROJECT_RESULTS,
+      type: 'ALL',
+    };
+    if (n) {
+      params.n = n;
+    }
+    return this._restApiHelper.fetchJSON({
+      url: '/projects/',
+      errFn,
+      params,
+      reportUrlAsIs: true,
+    });
+  }
+
+  getSuggestedAccounts(
+    inputVal: string,
+    n?: number,
+    errFn?: ErrorCallback
+  ): Promise<AccountInfo[] | undefined> {
+    if (!inputVal) {
+      return Promise.resolve([]);
+    }
+    const params: QueryAccountsParams = {suggest: null, q: inputVal};
+    if (n) {
+      params.n = n;
+    }
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/',
+      errFn,
+      params,
+      anonymizedUrl: '/accounts/?n=*',
+    }) as Promise<AccountInfo[] | undefined>;
+  }
+
+  addChangeReviewer(
+    changeNum: NumericChangeId,
+    reviewerID: AccountId | EmailAddress | GroupId
+  ) {
+    return this._sendChangeReviewerRequest(
+      HttpMethod.POST,
+      changeNum,
+      reviewerID
+    );
+  }
+
+  removeChangeReviewer(
+    changeNum: NumericChangeId,
+    reviewerID: AccountId | EmailAddress | GroupId
+  ) {
+    return this._sendChangeReviewerRequest(
+      HttpMethod.DELETE,
+      changeNum,
+      reviewerID
+    );
+  }
+
+  _sendChangeReviewerRequest(
+    method: HttpMethod.POST | HttpMethod.DELETE,
+    changeNum: NumericChangeId,
+    reviewerID: AccountId | EmailAddress | GroupId
+  ) {
+    return this.getChangeActionURL(changeNum, undefined, '/reviewers').then(
+      url => {
+        let body;
+        switch (method) {
+          case HttpMethod.POST:
+            body = {reviewer: reviewerID};
+            break;
+          case HttpMethod.DELETE:
+            url += '/' + encodeURIComponent(reviewerID);
+            break;
+          default:
+            assertNever(method, `Unsupported HTTP method: ${method}`);
+        }
+
+        return this._restApiHelper.send({method, url, body});
+      }
+    );
+  }
+
+  getRelatedChanges(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<RelatedChangesInfo | undefined> {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/related',
+      revision: patchNum,
+      reportEndpointAsIs: true,
+    }) as Promise<RelatedChangesInfo | undefined>;
+  }
+
+  getChangesSubmittedTogether(
+    changeNum: NumericChangeId
+  ): Promise<SubmittedTogetherInfo | undefined> {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
+      reportEndpointAsIs: true,
+    }) as Promise<SubmittedTogetherInfo | undefined>;
+  }
+
+  getChangeConflicts(
+    changeNum: NumericChangeId
+  ): Promise<ChangeInfo[] | undefined> {
+    const options = listChangesOptionsToHex(
+      ListChangesOption.CURRENT_REVISION,
+      ListChangesOption.CURRENT_COMMIT
+    );
+    const params = {
+      O: options,
+      q: `status:open conflicts:${changeNum}`,
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/conflicts:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
+  getChangeCherryPicks(
+    project: RepoName,
+    changeID: ChangeId,
+    changeNum: NumericChangeId
+  ): Promise<ChangeInfo[] | undefined> {
+    const options = listChangesOptionsToHex(
+      ListChangesOption.CURRENT_REVISION,
+      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:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
+  getChangesWithSameTopic(
+    topic: string,
+    changeNum: NumericChangeId
+  ): Promise<ChangeInfo[] | undefined> {
+    const options = listChangesOptionsToHex(
+      ListChangesOption.LABELS,
+      ListChangesOption.CURRENT_REVISION,
+      ListChangesOption.CURRENT_COMMIT,
+      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:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
+  getReviewedFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<string[] | undefined> {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/files?reviewed',
+      revision: patchNum,
+      reportEndpointAsIs: true,
+    }) as Promise<string[] | undefined>;
+  }
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean
+  ): Promise<Response>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE,
+      patchNum,
+      endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
+      errFn,
+      anonymizedEndpoint: '/files/*/reviewed',
+    });
+  }
+
+  saveChangeReview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    review: ReviewInput
+  ): Promise<Response>;
+
+  saveChangeReview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    review: ReviewInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  saveChangeReview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    review: ReviewInput,
+    errFn?: ErrorCallback
+  ) {
+    const promises: [Promise<void>, Promise<string>] = [
+      this.awaitPendingDiffDrafts(),
+      this.getChangeActionURL(changeNum, patchNum, '/review'),
+    ];
+    return Promise.all(promises).then(([, url]) =>
+      this._restApiHelper.send({
+        method: HttpMethod.POST,
+        url,
+        body: review,
+        errFn,
+      })
+    );
+  }
+
+  getChangeEdit(
+    changeNum: NumericChangeId,
+    downloadCommands?: boolean
+  ): Promise<false | EditInfo | undefined> {
+    const params = downloadCommands ? {'download-commands': true} : undefined;
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        return Promise.resolve(false);
+      }
+      return this._getChangeURLAndFetch(
+        {
+          changeNum,
+          endpoint: '/edit/',
+          params,
+          reportEndpointAsIs: true,
+        },
+        true
+      ) as Promise<EditInfo | false | undefined>;
+    });
+  }
+
+  createChange(
+    project: RepoName,
+    branch: BranchName,
+    subject: string,
+    topic?: string,
+    isPrivate?: boolean,
+    workInProgress?: boolean,
+    baseChange?: ChangeId,
+    baseCommit?: string
+  ) {
+    return (this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/changes/',
+      body: {
+        project,
+        branch,
+        subject,
+        topic,
+        is_private: isPrivate,
+        work_in_progress: workInProgress,
+        base_change: baseChange,
+        base_commit: baseCommit,
+      },
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as unknown) as Promise<ChangeInfo | undefined>;
+  }
+
+  getFileContent(
+    changeNum: NumericChangeId,
+    path: string,
+    patchNum: PatchSetNum
+  ): Promise<Response | Base64FileContent | undefined> {
+    // 404s indicate the file does not exist yet in the revision, so suppress
+    // them.
+    const suppress404s: ErrorCallback = res => {
+      if (res?.status !== 404) {
+        this.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail: {res},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      }
+      return res;
+    };
+    const promise = patchNumEquals(patchNum, EditPatchSetNum)
+      ? this._getFileInChangeEdit(changeNum, path)
+      : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
+
+    return promise.then(res => {
+      if (!res || !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 => {
+        const strContent = (content as unknown) as string | null;
+        return {content: strContent, type, ok: true};
+      });
+    });
+  }
+
+  /**
+   * Gets a file in a specific change and revision.
+   */
+  _getFileInRevision(
+    changeNum: NumericChangeId,
+    path: string,
+    patchNum: PatchSetNum,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.GET,
+      patchNum,
+      endpoint: `/files/${encodeURIComponent(path)}/content`,
+      errFn,
+      headers: {Accept: 'application/json'},
+      anonymizedEndpoint: '/files/*/content',
+    });
+  }
+
+  /**
+   * Gets a file in a change edit.
+   */
+  _getFileInChangeEdit(changeNum: NumericChangeId, path: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.GET,
+      endpoint: '/edit/' + encodeURIComponent(path),
+      headers: {Accept: 'application/json'},
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  rebaseChangeEdit(changeNum: NumericChangeId) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/edit:rebase',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteChangeEdit(changeNum: NumericChangeId) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: '/edit',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  restoreFileInChangeEdit(changeNum: NumericChangeId, restore_path: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/edit',
+      body: {restore_path},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  renameFileInChangeEdit(
+    changeNum: NumericChangeId,
+    old_path: string,
+    new_path: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/edit',
+      body: {old_path, new_path},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteFileInChangeEdit(changeNum: NumericChangeId, path: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: '/edit/' + encodeURIComponent(path),
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  saveChangeEdit(changeNum: NumericChangeId, path: string, contents: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/edit/' + encodeURIComponent(path),
+      body: contents,
+      contentType: 'text/plain',
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  saveFileUploadChangeEdit(
+    changeNum: NumericChangeId,
+    path: string,
+    content: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/edit/' + encodeURIComponent(path),
+      body: {binary_content: content},
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  getRobotCommentFixPreview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixId: FixId
+  ): Promise<FilePathToDiffInfoMap | undefined> {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      revision: patchNum,
+      endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
+      reportEndpointAsId: true,
+    }) as Promise<FilePathToDiffInfoMap | undefined>;
+  }
+
+  applyFixSuggestion(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixId: string
+  ): Promise<Response> {
+    return this._getChangeURLAndSend({
+      method: HttpMethod.POST,
+      changeNum,
+      patchNum,
+      endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
+      reportEndpointAsId: true,
+    });
+  }
+
+  // Deprecated, prefer to use putChangeCommitMessage instead.
+  saveChangeCommitMessageEdit(changeNum: NumericChangeId, message: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/edit:message',
+      body: {message},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  publishChangeEdit(changeNum: NumericChangeId) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/edit:publish',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  putChangeCommitMessage(changeNum: NumericChangeId, message: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/message',
+      body: {message},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteChangeCommitMessage(
+    changeNum: NumericChangeId,
+    messageId: ChangeMessageId
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: `/messages/${messageId}`,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  saveChangeStarred(
+    changeNum: NumericChangeId,
+    starred: boolean
+  ): Promise<Response> {
+    // 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 encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
+      const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
+      return this._restApiHelper.send({
+        method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
+        url,
+        anonymizedUrl: '/accounts/self/starred.changes/*',
+      });
+    });
+  }
+
+  saveChangeReviewed(
+    changeNum: NumericChangeId,
+    reviewed: boolean
+  ): Promise<Response | undefined> {
+    return this.getConfig().then(config => {
+      const isAttentionSetEnabled =
+        !!config && !!config.change && config.change.enable_attention_set;
+      if (isAttentionSetEnabled) return;
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: HttpMethod.PUT,
+        endpoint: reviewed ? '/reviewed' : '/unreviewed',
+      });
+    });
+  }
+
+  send(
+    method: HttpMethod,
+    url: string,
+    body?: RequestPayload,
+    errFn?: undefined,
+    contentType?: string,
+    headers?: Record<string, string>
+  ): Promise<Response>;
+
+  send(
+    method: HttpMethod,
+    url: string,
+    body: RequestPayload | undefined,
+    errFn: ErrorCallback,
+    contentType?: string,
+    headers?: Record<string, string>
+  ): Promise<Response | undefined>;
+
+  /**
+   * Public version of the _restApiHelper.send method preserved for plugins.
+   *
+   * @param body passed as null sometimes
+   * and also apparently a number. TODO (beckysiegel) remove need for
+   * number at least.
+   */
+  send(
+    method: HttpMethod,
+    url: string,
+    body?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string,
+    headers?: Record<string, string>
+  ): Promise<Response | undefined> {
+    return this._restApiHelper.send({
+      method,
+      url,
+      body,
+      errFn,
+      contentType,
+      headers,
+    });
+  }
+
+  /**
+   * @param basePatchNum Negative values specify merge parent
+   * index.
+   * @param whitespace the ignore-whitespace level for the diff
+   * algorithm.
+   */
+  getDiff(
+    changeNum: NumericChangeId,
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    path: string,
+    whitespace?: IgnoreWhitespaceType,
+    errFn?: ErrorCallback
+  ) {
+    const params: GetDiffParams = {
+      context: 'ALL',
+      intraline: null,
+      whitespace: whitespace || IgnoreWhitespaceType.IGNORE_NONE,
+    };
+    if (isMergeParent(basePatchNum)) {
+      params.parent = getParentIndex(basePatchNum);
+    } else if (!patchNumEquals(basePatchNum, ParentPatchSetNum)) {
+      // TODO (TS): fix as PatchSetNum in the condition above
+      params.base = basePatchNum;
+    }
+    const endpoint = `/files/${encodeURIComponent(path)}/diff`;
+    const req: FetchChangeJSON = {
+      changeNum,
+      endpoint,
+      revision: patchNum,
+      errFn,
+      params,
+      anonymizedEndpoint: '/files/*/diff',
+    };
+
+    // Invalidate the cache if its edit patch to make sure we always get latest.
+    if (patchNum === EditPatchSetNum) {
+      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) as Promise<DiffInfo | undefined>;
+  }
+
+  getDiffComments(
+    changeNum: NumericChangeId
+  ): Promise<PathToCommentsInfoMap | undefined>;
+
+  getDiffComments(
+    changeNum: NumericChangeId,
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    path: string
+  ): Promise<GetDiffCommentsOutput>;
+
+  getDiffComments(
+    changeNum: NumericChangeId,
+    basePatchNum?: PatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ) {
+    if (!basePatchNum && !patchNum && !path) {
+      return this._getDiffComments(changeNum, '/comments');
+    }
+    return this._getDiffComments(
+      changeNum,
+      '/comments',
+      basePatchNum,
+      patchNum,
+      path
+    );
+  }
+
+  getDiffRobotComments(
+    changeNum: NumericChangeId
+  ): Promise<PathToRobotCommentsInfoMap | undefined>;
+
+  getDiffRobotComments(
+    changeNum: NumericChangeId,
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    path: string
+  ): Promise<GetDiffRobotCommentsOutput>;
+
+  getDiffRobotComments(
+    changeNum: NumericChangeId,
+    basePatchNum?: PatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ) {
+    if (!basePatchNum && !patchNum && !path) {
+      return this._getDiffComments(changeNum, '/robotcomments');
+    }
+
+    return this._getDiffComments(
+      changeNum,
+      '/robotcomments',
+      basePatchNum,
+      patchNum,
+      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.
+   */
+  getDiffDrafts(
+    changeNum: NumericChangeId
+  ): Promise<PathToCommentsInfoMap | undefined>;
+
+  getDiffDrafts(
+    changeNum: NumericChangeId,
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    path: string
+  ): Promise<GetDiffCommentsOutput>;
+
+  getDiffDrafts(
+    changeNum: NumericChangeId,
+    basePatchNum?: PatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ) {
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        return {};
+      }
+      if (!basePatchNum && !patchNum && !path) {
+        return this._getDiffComments(changeNum, '/drafts');
+      }
+      return this._getDiffComments(
+        changeNum,
+        '/drafts',
+        basePatchNum,
+        patchNum,
+        path
+      );
+    });
+  }
+
+  _setRange(comments: CommentInfo[], comment: CommentInfo) {
+    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?: CommentInfo[]) {
+    comments = comments || [];
+    comments.sort(
+      (a, b) => parseDate(a.updated).valueOf() - parseDate(b.updated).valueOf()
+    );
+    for (const comment of comments) {
+      this._setRange(comments, comment);
+    }
+    return comments;
+  }
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: '/comments' | '/drafts'
+  ): Promise<PathToCommentsInfoMap | undefined>;
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: '/robotcomments'
+  ): Promise<PathToRobotCommentsInfoMap | undefined>;
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: '/comments' | '/drafts',
+    basePatchNum?: PatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ): Promise<GetDiffCommentsOutput>;
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: '/robotcomments',
+    basePatchNum?: PatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ): Promise<GetDiffRobotCommentsOutput>;
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: string,
+    basePatchNum?: PatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ): Promise<
+    | GetDiffCommentsOutput
+    | GetDiffRobotCommentsOutput
+    | PathToCommentsInfoMap
+    | PathToRobotCommentsInfoMap
+    | undefined
+  > {
+    /**
+     * Fetches the comments for a given patchNum.
+     * Helper function to make promises more legible.
+     */
+    // We don't want to add accept header, since preloading of comments is
+    // working only without accept header.
+    const noAcceptHeader = true;
+    const fetchComments = (patchNum?: PatchSetNum) =>
+      this._getChangeURLAndFetch(
+        {
+          changeNum,
+          endpoint,
+          revision: patchNum,
+          reportEndpointAsIs: true,
+        },
+        noAcceptHeader
+      ) as Promise<
+        PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined
+      >;
+
+    if (!basePatchNum && !patchNum && !path) {
+      return fetchComments();
+    }
+    function onlyParent(c: CommentInfo) {
+      return c.side === CommentSide.PARENT;
+    }
+    function withoutParent(c: CommentInfo) {
+      return c.side !== CommentSide.PARENT;
+    }
+    function setPath(c: CommentInfo) {
+      c.path = path;
+    }
+
+    const promises = [];
+    let comments: CommentInfo[];
+    let baseComments: CommentInfo[];
+    let fetchPromise;
+    fetchPromise = fetchComments(patchNum).then(response => {
+      comments = (response && path && response[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 (basePatchNum === ParentPatchSetNum) {
+        baseComments = comments.filter(onlyParent);
+        baseComments.forEach(setPath);
+      }
+      comments = comments.filter(withoutParent);
+
+      comments.forEach(setPath);
+    });
+    promises.push(fetchPromise);
+
+    if (basePatchNum !== ParentPatchSetNum) {
+      fetchPromise = fetchComments(basePatchNum).then(response => {
+        baseComments = ((response && path && response[path]) || []).filter(
+          withoutParent
+        );
+        baseComments = this._setRanges(baseComments);
+        baseComments.forEach(setPath);
+      });
+      promises.push(fetchPromise);
+    }
+
+    return Promise.all(promises).then(() =>
+      Promise.resolve({
+        baseComments,
+        comments,
+      })
+    );
+  }
+
+  _getDiffCommentsFetchURL(
+    changeNum: NumericChangeId,
+    endpoint: string,
+    patchNum?: RevisionId
+  ) {
+    return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
+  }
+
+  saveDiffDraft(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: CommentInput
+  ) {
+    return this._sendDiffDraftRequest(
+      HttpMethod.PUT,
+      changeNum,
+      patchNum,
+      draft
+    );
+  }
+
+  deleteDiffDraft(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: {id: UrlEncodedCommentId}
+  ) {
+    return this._sendDiffDraftRequest(
+      HttpMethod.DELETE,
+      changeNum,
+      patchNum,
+      draft
+    );
+  }
+
+  /**
+   * @returns Whether there are pending diff draft sends.
+   */
+  hasPendingDiffDrafts(): number {
+    const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
+    return promises && promises.length;
+  }
+
+  /**
+   * @returns A promise that resolves when all pending
+   * diff draft sends have resolved.
+   */
+  awaitPendingDiffDrafts(): Promise<void> {
+    return Promise.all(
+      this._pendingRequests[Requests.SEND_DIFF_DRAFT] || []
+    ).then(() => {
+      this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
+    });
+  }
+
+  _sendDiffDraftRequest(
+    method: HttpMethod.PUT,
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: CommentInput
+  ): Promise<Response>;
+
+  _sendDiffDraftRequest(
+    method: HttpMethod.GET | HttpMethod.DELETE,
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: {id?: UrlEncodedCommentId}
+  ): Promise<Response>;
+
+  _sendDiffDraftRequest(
+    method: HttpMethod,
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: CommentInput | {id: UrlEncodedCommentId}
+  ): Promise<Response> {
+    const isCreate = !draft.id && method === HttpMethod.PUT;
+    let endpoint = '/drafts';
+    let anonymizedEndpoint = endpoint;
+    if (draft.id) {
+      endpoint += `/${draft.id}`;
+      anonymizedEndpoint += '/*';
+    }
+    let body;
+    if (method === HttpMethod.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: RepoName,
+    commit: CommitId
+  ): Promise<CommitInfo | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url:
+        '/projects/' +
+        encodeURIComponent(project) +
+        '/commits/' +
+        encodeURIComponent(commit),
+      anonymizedUrl: '/projects/*/comments/*',
+    }) as Promise<CommitInfo | undefined>;
+  }
+
+  _fetchB64File(url: string): Promise<Base64File> {
+    return this._restApiHelper
+      .fetch({url: 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};
+        });
+      });
+  }
+
+  getB64FileContents(
+    changeId: NumericChangeId,
+    patchNum: RevisionId,
+    path: string,
+    parentIndex?: number
+  ) {
+    const parent =
+      typeof parentIndex === 'number' ? `?parent=${parentIndex}` : '';
+    return this._changeBaseURL(changeId, patchNum).then(url => {
+      url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
+      return this._fetchB64File(url);
+    });
+  }
+
+  getImagesForDiff(
+    changeNum: NumericChangeId,
+    diff: DiffInfo,
+    patchRange: PatchRange
+  ): Promise<ImagesForDiff> {
+    let promiseA;
+    let promiseB;
+
+    if (diff.meta_a?.content_type.startsWith('image/')) {
+      if (patchRange.basePatchNum === ParentPatchSetNum) {
+        // 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
+        );
+      }
+    } else {
+      promiseA = Promise.resolve(null);
+    }
+
+    if (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 => {
+      // Sometimes the server doesn't send back the content type.
+      const baseImage: Base64ImageFile | null = results[0]
+        ? {
+            ...results[0],
+            _expectedType: diff.meta_a.content_type,
+            _name: diff.meta_a.name,
+          }
+        : null;
+      const revisionImage: Base64ImageFile | null = results[1]
+        ? {
+            ...results[1],
+            _expectedType: diff.meta_b.content_type,
+            _name: diff.meta_b.name,
+          }
+        : null;
+      const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
+      return imagesForDiff;
+    });
+  }
+
+  _changeBaseURL(
+    changeNum: NumericChangeId,
+    revisionId?: RevisionId,
+    project?: RepoName
+  ): Promise<string> {
+    // TODO(kaspern): For full slicer migration, app should warn with a call
+    // stack every time _changeBaseURL is called without a project.
+    const projectPromise = project
+      ? Promise.resolve(project)
+      : this.getFromProjectLookup(changeNum);
+    return projectPromise.then(project => {
+      // TODO(TS): unclear why project can't be null here. Fix it
+      let url = `/changes/${encodeURIComponent(
+        project as RepoName
+      )}~${changeNum}`;
+      if (revisionId) {
+        url += `/revisions/${revisionId}`;
+      }
+      return url;
+    });
+  }
+
+  addToAttentionSet(
+    changeNum: NumericChangeId,
+    user: AccountId | undefined | null,
+    reason: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/attention',
+      body: {user, reason},
+      reportUrlAsIs: true,
+    });
+  }
+
+  removeFromAttentionSet(
+    changeNum: NumericChangeId,
+    user: AccountId,
+    reason: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: `/attention/${user}`,
+      anonymizedEndpoint: '/attention/*',
+      body: {reason},
+    });
+  }
+
+  setChangeTopic(
+    changeNum: NumericChangeId,
+    topic: string | null
+  ): Promise<string> {
+    return (this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/topic',
+      body: {topic},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as unknown) as Promise<string>;
+  }
+
+  setChangeHashtag(
+    changeNum: NumericChangeId,
+    hashtag: HashtagsInput
+  ): Promise<Hashtag[]> {
+    return (this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/hashtags',
+      body: hashtag,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as unknown) as Promise<Hashtag[]>;
+  }
+
+  deleteAccountHttpPassword() {
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: '/accounts/self/password.http',
+      reportUrlAsIs: true,
+    });
+  }
+
+  generateAccountHttpPassword(): Promise<Password> {
+    return (this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: '/accounts/self/password.http',
+      body: {generate: true},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as Promise<unknown>) as Promise<Password>;
+  }
+
+  getAccountSSHKeys() {
+    return (this._fetchSharedCacheURL({
+      url: '/accounts/self/sshkeys',
+      reportUrlAsIs: true,
+    }) as Promise<unknown>) as Promise<SshKeyInfo[] | undefined>;
+  }
+
+  addAccountSSHKey(key: string): Promise<SshKeyInfo> {
+    const req = {
+      method: HttpMethod.POST,
+      url: '/accounts/self/sshkeys',
+      body: key,
+      contentType: 'text/plain',
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then((response: Response | undefined) => {
+        if (!response || (response.status < 200 && response.status >= 300)) {
+          return Promise.reject(new Error('error'));
+        }
+        return (this.getResponseObject(response) as unknown) as Promise<
+          SshKeyInfo
+        >;
+      })
+      .then(obj => {
+        if (!obj || !obj.valid) {
+          return Promise.reject(new Error('error'));
+        }
+        return obj;
+      });
+  }
+
+  deleteAccountSSHKey(id: string) {
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: '/accounts/self/sshkeys/' + id,
+      anonymizedUrl: '/accounts/self/sshkeys/*',
+    });
+  }
+
+  getAccountGPGKeys() {
+    return (this._restApiHelper.fetchJSON({
+      url: '/accounts/self/gpgkeys',
+      reportUrlAsIs: true,
+    }) as Promise<unknown>) as Promise<Record<string, GpgKeyInfo>>;
+  }
+
+  addAccountGPGKey(key: GpgKeysInput) {
+    const req = {
+      method: HttpMethod.POST,
+      url: '/accounts/self/gpgkeys',
+      body: key,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then(response => {
+        if (!response || (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: GpgKeyId) {
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: `/accounts/self/gpgkeys/${id}`,
+      anonymizedUrl: '/accounts/self/gpgkeys/*',
+    });
+  }
+
+  deleteVote(changeNum: NumericChangeId, account: AccountId, label: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+      anonymizedEndpoint: '/reviewers/*/votes/*',
+    });
+  }
+
+  setDescription(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    desc: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      patchNum,
+      endpoint: '/description',
+      body: {description: desc},
+      reportUrlAsIs: true,
+    });
+  }
+
+  confirmEmail(token: string): Promise<string | null> {
+    const req = {
+      method: HttpMethod.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(
+    errFn?: ErrorCallback
+  ): Promise<CapabilityInfoMap | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: '/config/server/capabilities',
+      errFn,
+      reportUrlAsIs: true,
+    }) as Promise<CapabilityInfoMap | undefined>;
+  }
+
+  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined> {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/top-menus',
+      errFn,
+      reportUrlAsIs: true,
+    }) as Promise<TopMenuEntryInfo[] | undefined>;
+  }
+
+  setAssignee(
+    changeNum: NumericChangeId,
+    assignee: AccountId
+  ): Promise<Response> {
+    const body: AssigneeInput = {assignee};
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/assignee',
+      body,
+      reportUrlAsIs: true,
+    });
+  }
+
+  deleteAssignee(changeNum: NumericChangeId): Promise<Response> {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: '/assignee',
+      reportUrlAsIs: true,
+    });
+  }
+
+  probePath(path: string) {
+    return fetch(new Request(path, {method: HttpMethod.HEAD})).then(
+      response => response.ok
+    );
+  }
+
+  startWorkInProgress(
+    changeNum: NumericChangeId,
+    message?: string
+  ): Promise<string | undefined> {
+    const body = message ? {message} : {};
+    const req: SendRawChangeRequest = {
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/wip',
+      body,
+      reportUrlAsIs: true,
+    };
+    return this._getChangeURLAndSend(req).then(response => {
+      if (response?.status === 204) {
+        return 'Change marked as Work In Progress.';
+      }
+      return undefined;
+    });
+  }
+
+  startReview(
+    changeNum: NumericChangeId,
+    body?: RequestPayload,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/ready',
+      body,
+      errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  deleteComment(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    commentID: UrlEncodedCommentId,
+    reason: string
+  ) {
+    return (this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      patchNum,
+      endpoint: `/comments/${commentID}/delete`,
+      body: {reason},
+      parseResponse: true,
+      anonymizedEndpoint: '/comments/*/delete',
+    }) as unknown) as Promise<CommentInfo>;
+  }
+
+  /**
+   * Given a changeNum, gets the change.
+   */
+  getChange(
+    changeNum: ChangeId | NumericChangeId,
+    errFn: ErrorCallback
+  ): Promise<ChangeInfo | null> {
+    // Cannot use _changeBaseURL, as this function is used by _projectLookup.
+    return this._restApiHelper
+      .fetchJSON({
+        url: `/changes/?q=change:${changeNum}`,
+        errFn,
+        anonymizedUrl: '/changes/?q=change:*',
+      })
+      .then(res => {
+        const changeInfos = res as ChangeInfo[] | undefined;
+        if (!changeInfos || !changeInfos.length) {
+          return null;
+        }
+        return changeInfos[0];
+      });
+  }
+
+  setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
+    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;
+  }
+
+  /**
+   * 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.
+   */
+  getFromProjectLookup(
+    changeNum: NumericChangeId
+  ): Promise<RepoName | undefined> {
+    const project = this._projectLookup[`${changeNum}`];
+    if (project) {
+      return Promise.resolve(project);
+    }
+
+    const onError = (response?: Response | null) => {
+      // 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;
+    });
+  }
+
+  // if errFn is not set, then only Response possible
+  _getChangeURLAndSend(
+    req: SendRawChangeRequest & {errFn?: undefined}
+  ): Promise<Response>;
+
+  _getChangeURLAndSend(
+    req: SendRawChangeRequest
+  ): Promise<Response | undefined>;
+
+  _getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>;
+
+  /**
+   * Alias for _changeBaseURL.then(send).
+   */
+  _getChangeURLAndSend(
+    req: SendChangeRequest
+  ): Promise<ParsedJSON | Response | undefined> {
+    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 => {
+      const request: SendRequest = {
+        method: req.method,
+        url: url + req.endpoint,
+        body: req.body,
+        errFn: req.errFn,
+        contentType: req.contentType,
+        headers: req.headers,
+        parseResponse: req.parseResponse,
+        anonymizedUrl: anonymizedEndpoint
+          ? `${anonymizedBaseUrl}${anonymizedEndpoint}`
+          : undefined,
+      };
+      return this._restApiHelper.send(request);
+    });
+  }
+
+  /**
+   * Alias for _changeBaseURL.then(_fetchJSON).
+   */
+  _getChangeURLAndFetch(
+    req: FetchChangeJSON,
+    noAcceptHeader?: boolean
+  ): Promise<ParsedJSON | undefined> {
+    const anonymizedEndpoint = req.reportEndpointAsIs
+      ? req.endpoint
+      : req.anonymizedEndpoint;
+    const anonymizedBaseUrl = req.revision
+      ? ANONYMIZED_REVISION_BASE_URL
+      : ANONYMIZED_CHANGE_BASE_URL;
+    return this._changeBaseURL(req.changeNum, req.revision).then(url =>
+      this._restApiHelper.fetchJSON(
+        {
+          url: url + req.endpoint,
+          errFn: req.errFn,
+          params: req.params,
+          fetchOptions: req.fetchOptions,
+          anonymizedUrl: anonymizedEndpoint
+            ? anonymizedBaseUrl + anonymizedEndpoint
+            : undefined,
+        },
+        noAcceptHeader
+      )
+    );
+  }
+
+  executeChangeAction(
+    changeNum: NumericChangeId,
+    method: HttpMethod | undefined,
+    endpoint: string,
+    patchNum?: PatchSetNum,
+    payload?: RequestPayload
+  ): Promise<Response>;
+
+  executeChangeAction(
+    changeNum: NumericChangeId,
+    method: HttpMethod | undefined,
+    endpoint: string,
+    patchNum: PatchSetNum | undefined,
+    payload: RequestPayload | undefined,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  /**
+   * Execute a change action or revision action on a change.
+   */
+  executeChangeAction(
+    changeNum: NumericChangeId,
+    method: HttpMethod | undefined,
+    endpoint: string,
+    patchNum?: PatchSetNum,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method,
+      patchNum,
+      endpoint,
+      body: payload,
+      errFn,
+    });
+  }
+
+  /**
+   * Get blame information for the given diff.
+   *
+   * @param base If true, requests blame for the base of the
+   *     diff, rather than the revision.
+   */
+  getBlame(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    base?: boolean
+  ) {
+    const encodedPath = encodeURIComponent(path);
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/files/${encodedPath}/blame`,
+      revision: patchNum,
+      params: base ? {base: 't'} : undefined,
+      anonymizedEndpoint: '/files/*/blame',
+    }) as Promise<BlameInfo[] | undefined>;
+  }
+
+  /**
+   * 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 The original promise.
+   * @return The modified promise.
+   */
+  _failForCreate200(promise: Promise<Response>): Promise<Response> {
+    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_BLOCK_REGEX.test(key)) {
+              obj[key] = val;
+            }
+            return obj;
+          },
+          {} as Record<string, string>
+        );
+        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
+   */
+  getDashboard(
+    project: RepoName,
+    dashboard: DashboardId,
+    errFn?: ErrorCallback
+  ): Promise<DashboardInfo | undefined> {
+    const url =
+      '/projects/' +
+      encodeURIComponent(project) +
+      '/dashboards/' +
+      encodeURIComponent(dashboard);
+    return this._fetchSharedCacheURL({
+      url,
+      errFn,
+      anonymizedUrl: '/projects/*/dashboards/*',
+    }) as Promise<DashboardInfo | undefined>;
+  }
+
+  getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
+    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/?*',
+    }) as Promise<DocResult[] | undefined>;
+  }
+
+  getMergeable(changeNum: NumericChangeId) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/revisions/current/mergeable',
+      reportEndpointAsIs: true,
+    }) as Promise<MergeableInfo | undefined>;
+  }
+
+  deleteDraftComments(query: string): Promise<Response> {
+    const body: DeleteDraftCommentsInput = {query};
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/accounts/self/drafts:delete',
+      body,
+    });
+  }
+}
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
deleted file mode 100644
index 0a51d26..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ /dev/null
@@ -1,1449 +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">
-<meta charset="utf-8">
-<title>gr-rest-api-interface</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-rest-api-interface></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<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';
-
-suite('gr-rest-api-interface tests', () => {
-  let element;
-  let sandbox;
-  let ctr = 0;
-
-  setup(() => {
-    // Modify CANONICAL_PATH to effectively reset cache.
-    ctr += 1;
-    window.CANONICAL_PATH = `test${ctr}`;
-
-    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 = {};
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('parent diff comments are properly grouped', done => {
-    sandbox.stub(element._restApiHelper, 'fetchJSON', () => Promise.resolve({
-      '/COMMIT_MSG': [],
-      'sieve.go': [
-        {
-          updated: '2017-02-03 22:32:28.000000000',
-          message: 'this isn’t quite right',
-        },
-        {
-          side: 'PARENT',
-          message: 'how did this work in the first place?',
-          updated: '2017-02-03 22:33:28.000000000',
-        },
-      ],
-    }));
-    element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
-        obj => {
-          assert.equal(obj.baseComments.length, 1);
-          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',
-        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], {
-            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);
-    });
-
-    element._restApiHelper.fetchJSON({}).then(response => {
-      assert.isUndefined(response);
-      assert.isTrue(getResponseObjectStub.notCalled);
-      serverErrorEventPromise.then(() => done());
-    });
-  });
-
-  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();
-      });
-
-  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({});
-  });
-
-  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('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');
-  });
-
-  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();
-    });
-  });
-
-  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', () => 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');
-    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('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-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
new file mode 100644
index 0000000..505fd58
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
@@ -0,0 +1,1369 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-rest-api-interface.js';
+import {mockPromise} from '../../../test/test-utils.js';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
+import {ListChangesOption} from '../../../utils/change-util.js';
+import {appContext} from '../../../services/app-context.js';
+
+const basicFixture = fixtureFromElement('gr-rest-api-interface');
+
+suite('gr-rest-api-interface tests', () => {
+  let element;
+
+  let ctr = 0;
+  let originalCanonicalPath;
+
+  setup(() => {
+    // Modify CANONICAL_PATH to effectively reset cache.
+    ctr += 1;
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = `test${ctr}`;
+
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sinon.stub(window, 'fetch').returns(Promise.resolve({
+      ok: true,
+      text() {
+        return Promise.resolve(testJSON);
+      },
+    }));
+    // fake auth
+    sinon.stub(appContext.authService, 'authCheck')
+        .returns(Promise.resolve(true));
+    element = basicFixture.instantiate();
+    element._projectLookup = {};
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('parent diff comments are properly grouped', () => {
+    sinon.stub(element._restApiHelper, 'fetchJSON')
+        .callsFake(() => 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',
+            },
+          ],
+        }));
+    return 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',
+          });
+        });
+  });
+
+  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',
+        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', () => {
+    sinon.stub(element, 'getFromProjectLookup')
+        .returns(Promise.resolve('test'));
+    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(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',
+            },
+          ],
+        });
+      }
+    });
+    return 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',
+          });
+        });
+  });
+
+  test('server error', () => {
+    const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
+    window.fetch.returns(Promise.resolve({ok: false}));
+    const serverErrorEventPromise = new Promise(resolve => {
+      element.addEventListener('server-error', resolve);
+    });
+
+    return Promise.all([element._restApiHelper.fetchJSON({}).then(response => {
+      assert.isUndefined(response);
+      assert.isTrue(getResponseObjectStub.notCalled);
+    }), serverErrorEventPromise]);
+  });
+
+  test('legacy n,z key in change url is replaced', async () => {
+    sinon.stub(element, 'getConfig').callsFake(async () => {
+      return {};
+    });
+    const stub = sinon.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 = sinon.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 to cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
+        .callsFake(() => Promise.resolve());
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+
+    element._restApiHelper._cache.set(cacheKey, 'fake cache');
+    stub.lastCall.args[0].errFn();
+  });
+
+  test('getAccount does not add to cache when status is 403', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
+        .callsFake(() => Promise.resolve());
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+
+    element._cache.set(cacheKey, 'fake cache');
+    stub.lastCall.args[0].errFn({status: 403});
+  });
+
+  test('getAccount when resp is successful', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL').callsFake(
+        () => Promise.resolve());
+
+    await element.getAccount();
+
+    element._restApiHelper._cache.set(cacheKey, 'fake cache');
+    assert.isTrue(stub.called);
+    assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
+    stub.lastCall.args[0].errFn({});
+  });
+
+  const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+    sinon.stub(element, 'getLoggedIn')
+        .callsFake(() => Promise.resolve(loggedIn));
+    sinon.stub(element, '_isNarrowScreen').callsFake(() => smallScreen);
+    sinon.stub(
+        element._restApiHelper,
+        'fetchCacheURL')
+        .callsFake(() => Promise.resolve(testJSON));
+  };
+
+  test('getPreferences returns correctly on small screens logged in',
+      () => {
+        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+        const loggedIn = true;
+        const smallScreen = true;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        return element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+        });
+      });
+
+  test('getPreferences returns correctly on small screens not logged in',
+      () => {
+        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+        const loggedIn = false;
+        const smallScreen = true;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+        return element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+        });
+      });
+
+  test('getPreferences returns correctly on larger screens logged in',
+      () => {
+        const testJSON = {diff_view: 'UNIFIED_DIFF'};
+        const loggedIn = true;
+        const smallScreen = false;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        return element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'UNIFIED_DIFF');
+        });
+      });
+
+  test('getPreferences returns correctly on larger screens not logged in',
+      () => {
+        const testJSON = {diff_view: 'UNIFIED_DIFF'};
+        const loggedIn = false;
+        const smallScreen = false;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        return element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+        });
+      });
+
+  test('savPreferences normalizes download scheme', () => {
+    const sendStub = sinon.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', () => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    return 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');
+    });
+  });
+
+  test('saveDiffPreferences set show_tabs to false', () => {
+    const sendStub = sinon.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', () => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    return 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');
+    });
+  });
+
+  test('saveEditPreferences set show_tabs to false', () => {
+    const sendStub = sinon.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 = sinon.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('setAccountStatus', () => {
+    const sendStub = sinon.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;
+      sinon.stub(element, '_getChangeURLAndSend')
+          .callsFake(() => 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();
+        sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
+        const failStub = sinon.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', () => {
+        sinon.stub(element, '_getChangeURLAndSend')
+            .returns(Promise.resolve());
+        const failStub = sinon.stub(element, '_failForCreate200')
+            .returns(Promise.resolve());
+        return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
+            .then(() => {
+              assert.isFalse(failStub.called);
+            });
+      });
+
+      test('_failForCreate200 fails on 200', () => {
+        const result = {
+          ok: true,
+          status: 200,
+          headers: {
+            entries: () => [
+              ['Set-CoOkiE', 'secret'],
+              ['Innocuous', 'hello'],
+            ],
+          },
+        };
+        return element._failForCreate200(Promise.resolve(result))
+            .then(() => {
+              assert.fail('Error expected.');
+            })
+            .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');
+            });
+      });
+
+      test('_failForCreate200 does not fail on 201', () => {
+        const result = {
+          ok: true,
+          status: 201,
+          headers: {entries: () => []},
+        };
+        return element._failForCreate200(Promise.resolve(result));
+      });
+    });
+  });
+
+  test('saveChangeEdit', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const file_name = 'index.php';
+    const file_contents = '<?php';
+    sinon.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, file_name, file_contents]));
+    sinon.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';
+    sinon.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, message]));
+    sinon.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';
+    sinon.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, messageId]));
+    sinon.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 = sinon.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 = sinon.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 = sinon.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 = sinon.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 = sinon.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].revision, '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 =
+          sinon.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 =
+          sinon.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', () => {
+    sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve());
+    element._restApiHelper.fetchJSON({url: 'foo'});
+    assert(appContext.authService.fetch.called);
+  });
+
+  test('getSuggestedAccounts does not return _fetchJSON', () => {
+    const _fetchJSONSpy = sinon.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 = sinon.stub(element._restApiHelper, 'fetchJSON')
+        .callsFake(() => Promise.resolve());
+    return element.getSuggestedAccounts('own').then(() => {
+      assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
+        q: 'own',
+        suggest: null,
+      });
+    });
+  });
+
+  suite('getChangeDetail', () => {
+    suite('change detail options', () => {
+      setup(() => {
+        sinon.stub(element, '_getChangeDetail').callsFake(
+            async (changeNum, options) => {
+              return {changeNum, options};
+            });
+      });
+
+      test('signed pushes disabled', async () => {
+        sinon.stub(element, 'getConfig').callsFake(async () => {
+          return {};
+        });
+        const {changeNum, options} = await element.getChangeDetail(123);
+        assert.strictEqual(123, changeNum);
+        assert.isNotOk(
+            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES));
+      });
+
+      test('signed pushes enabled', async () => {
+        sinon.stub(element, 'getConfig').callsFake(async () => {
+          return {receive: {enable_signed_push: true}};
+        });
+        const {changeNum, options} = await element.getChangeDetail(123);
+        assert.strictEqual(123, changeNum);
+        assert.ok(
+            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES));
+      });
+    });
+
+    test('GrReviewerUpdatesParser.parse is used', () => {
+      sinon.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?O=516714';
+      sinon.stub(element._etags, 'getOptions');
+      sinon.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();
+      sinon.stub(element, 'getChangeActionURL')
+          .returns(Promise.resolve(''));
+      sinon.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', () => {
+      sinon.stub(element, 'getChangeActionURL')
+          .returns(Promise.resolve(''));
+      sinon.stub(element._restApiHelper, 'fetchRawJSON')
+          .returns(Promise.resolve({ok: true}));
+
+      const mockResponse = {_number: 1, project: 'test'};
+      sinon.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;
+
+      setup(() => {
+        requestUrl = '/foo/bar';
+        const mockResponse = {foo: 'bar', baz: 42};
+        mockResponseSerial = element.JSON_PREFIX +
+            JSON.stringify(mockResponse);
+        sinon.stub(element._restApiHelper, 'urlWithParams')
+            .returns(requestUrl);
+        sinon.stub(element, 'getChangeActionURL')
+            .returns(Promise.resolve(requestUrl));
+        collectSpy = sinon.spy(element._etags, 'collect');
+      });
+
+      test('contributes to cache', () => {
+        const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
+        sinon.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', () => {
+        const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
+        getPayloadStub.returns(mockResponseSerial);
+        sinon.stub(element._restApiHelper, 'fetchRawJSON')
+            .returns(Promise.resolve({
+              text: () => Promise.resolve(''),
+              status: 304,
+              ok: true,
+            }));
+
+        return element._getChangeDetail(123, '').then(detail => {
+          assert.isFalse(collectSpy.called);
+          assert.isTrue(getPayloadStub.calledOnce);
+        });
+      });
+    });
+  });
+
+  test('setInProjectLookup', () => {
+    element.setInProjectLookup('test', 'project');
+    assert.deepEqual(element._projectLookup, {test: 'project'});
+  });
+
+  suite('getFromProjectLookup', () => {
+    test('getChange fails', () => {
+      sinon.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', () => {
+      sinon.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', () => {
+      sinon.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', () => {
+      sinon.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', () => {
+      sinon.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 = sinon.stub(element._restApiHelper, 'fetchJSON')
+        .returns(Promise.resolve());
+    const req = {changeNum: 1, endpoint: '/test', revision: 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 = sinon.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 = sinon.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 = sinon.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 = sinon.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 = sinon.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].revision, 2);
+        assert.isNotOk(fetchStub.lastCall.args[0].params);
+      });
+    });
+
+    test('simple range', () => {
+      const fetchStub = sinon.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].revision, 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 = sinon.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].revision, 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 = sinon.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].revision, 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 = sinon.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].revision, 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 = sinon.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].revision, 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 = sinon.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', () => {
+    sinon.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve({
+          ok: 'true',
+          headers: {
+            get(header) {
+              if (header === 'X-FYI-Content-Type') {
+                return 'text/java';
+              }
+            },
+          },
+        }));
+
+    sinon.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', () => {
+    const res = {status: 404};
+    const spy = sinon.spy();
+    element.addEventListener('server-error', spy);
+    sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve(res));
+    sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
+    return element.getFileContent('1', 'tst/path', '1')
+        .then(() => {
+          flush();
+          assert.isFalse(spy.called);
+
+          res.status = 500;
+          return element.getFileContent('1', 'tst/path', '1');
+        })
+        .then(() => {
+          assert.isTrue(spy.called);
+          assert.notEqual(spy.lastCall.args[0].detail.res.status, 404);
+        });
+  });
+
+  test('getChangeFilesOrEditFiles is edit-sensitive', () => {
+    const fn = element.getChangeOrEditFiles.bind(element);
+    const getChangeFilesStub = sinon.stub(element, 'getChangeFiles')
+        .returns(Promise.resolve({}));
+    const getChangeEditFilesStub = sinon.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 = sinon.stub(element._restApiHelper, '_logCall');
+    const response = {status: 404, text: sinon.stub()};
+    const url = 'my url';
+    const fetchOptions = {method: 'DELETE'};
+    sinon.stub(element.authService, 'fetch').returns(Promise.resolve(response));
+    const startTime = 123;
+    sinon.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', () => {
+    sinon.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);
+    flush();
+    assert.isTrue(handler.calledOnce);
+  });
+
+  test('saveChangeStarred', async () => {
+    sinon.stub(element, 'getFromProjectLookup')
+        .returns(Promise.resolve('test'));
+    const sendStub =
+        sinon.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/*',
+    });
+  });
+});
+
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
deleted file mode 100644
index bc70791..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
+++ /dev/null
@@ -1,406 +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.
- */
-const JSON_PREFIX = ')]}\'';
-
-/**
- * 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;
-  }
-
-  /**
-   * 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.dispatchEvent(new CustomEvent('rpc-log', {
-        detail: {status, method, elapsed, anonymizedUrl: req.anonymizedUrl},
-        composed: true, bubbles: true,
-      }));
-    }
-  }
-
-  /**
-   * 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.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
-   * @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.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
new file mode 100644
index 0000000..6d93604
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -0,0 +1,566 @@
+/**
+ * @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 {getBaseUrl} from '../../../../utils/url-util';
+import {
+  CancelConditionCallback,
+  ErrorCallback,
+  RestApiService,
+} from '../../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AuthRequestInit,
+  AuthService,
+} from '../../../../services/gr-auth/gr-auth';
+import {hasOwnProperty} from '../../../../utils/common-util';
+import {
+  AccountDetailInfo,
+  EmailInfo,
+  ParsedJSON,
+  RequestPayload,
+} from '../../../../types/common';
+import {HttpMethod} from '../../../../constants/constants';
+import {RpcLogEventDetail} from '../../../../types/events';
+
+const JSON_PREFIX = ")]}'";
+
+export interface ResponsePayload {
+  // TODO(TS): readResponsePayload can assign null to the parsed property if
+  // it can't parse input data. However polygerrit assumes in many places
+  // that the parsed property can't be null. We should update
+  // readResponsePayload method and reject a promise instead of assigning
+  // null to the parsed property
+  parsed: ParsedJSON; // Can be null!!! See comment above
+  raw: string;
+}
+
+/**
+ * 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 {
+  // TODO(TS): Type looks unusual. Fix it.
+  // Container of per-canonical-path caches.
+  private readonly _data = new Map<
+    string | undefined,
+    unknown | Map<string, ParsedJSON | null>
+  >();
+
+  constructor() {
+    if (window.INITIAL_DATA) {
+      // 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] as unknown) as ParsedJSON)
+      );
+    }
+  }
+
+  // Returns the cache for the current canonical path.
+  _cache(): Map<string, unknown> {
+    if (!this._data.has(window.CANONICAL_PATH)) {
+      this._data.set(window.CANONICAL_PATH, new Map());
+    }
+    return this._data.get(window.CANONICAL_PATH) as Map<
+      string,
+      ParsedJSON | null
+    >;
+  }
+
+  has(key: string) {
+    return this._cache().has(key);
+  }
+
+  get(key: '/accounts/self/emails'): EmailInfo[] | null;
+
+  get(key: '/accounts/self/detail'): AccountDetailInfo[] | null;
+
+  get(key: string): ParsedJSON | null;
+
+  get(key: string): unknown {
+    return this._cache().get(key);
+  }
+
+  set(key: '/accounts/self/emails', value: EmailInfo[]): void;
+
+  set(key: '/accounts/self/detail', value: AccountDetailInfo[]): void;
+
+  set(key: string, value: ParsedJSON | null): void;
+
+  set(key: string, value: unknown) {
+    this._cache().set(key, value);
+  }
+
+  delete(key: string) {
+    this._cache().delete(key);
+  }
+
+  invalidatePrefix(prefix: string) {
+    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);
+  }
+}
+
+type FetchPromisesCacheData = {
+  [url: string]: Promise<ParsedJSON | undefined> | undefined;
+};
+
+export class FetchPromisesCache {
+  private _data: FetchPromisesCacheData;
+
+  constructor() {
+    this._data = {};
+  }
+
+  public testOnlyGetData() {
+    return this._data;
+  }
+
+  /**
+   * @return true only if a value for a key sets and it is not undefined
+   */
+  has(key: string): boolean {
+    return !!this._data[key];
+  }
+
+  get(key: string) {
+    return this._data[key];
+  }
+
+  /**
+   * @param value a Promise to store in the cache. Pass undefined value to
+   *     mark key as deleted.
+   */
+  set(key: string, value: Promise<ParsedJSON | undefined> | undefined) {
+    this._data[key] = value;
+  }
+
+  invalidatePrefix(prefix: string) {
+    const newData: FetchPromisesCacheData = {};
+    Object.entries(this._data).forEach(([key, value]) => {
+      if (!key.startsWith(prefix)) {
+        newData[key] = value;
+      }
+    });
+    this._data = newData;
+  }
+}
+export type FetchParams = {
+  [name: string]: string[] | string | number | boolean | undefined | null;
+};
+
+interface SendRequestBase {
+  method: HttpMethod | undefined;
+  body?: RequestPayload;
+  contentType?: string;
+  headers?: Record<string, string>;
+  url: string;
+  reportUrlAsIs?: boolean;
+  anonymizedUrl?: string;
+  errFn?: ErrorCallback;
+}
+
+export interface SendRawRequest extends SendRequestBase {
+  parseResponse?: false | null;
+}
+
+export interface SendJSONRequest extends SendRequestBase {
+  parseResponse: true;
+}
+
+export type SendRequest = SendRawRequest | SendJSONRequest;
+
+export interface FetchRequest {
+  url: string;
+  fetchOptions?: AuthRequestInit;
+  anonymizedUrl?: string;
+}
+
+export interface FetchJSONRequest extends FetchRequest {
+  reportUrlAsIs?: boolean;
+  params?: FetchParams;
+  cancelCondition?: CancelConditionCallback;
+  errFn?: ErrorCallback;
+}
+
+// export function isRequestWithCancel<T extends FetchJSONRequest>(
+//   x: T
+// ): x is T & RequestWithCancel {
+//   return !!(x as RequestWithCancel).cancelCondition;
+// }
+//
+// export function isRequestWithErrFn<T extends FetchJSONRequest>(
+//   x: T
+// ): x is T & RequestWithErrFn {
+//   return !!(x as RequestWithErrFn).errFn;
+// }
+
+export class GrRestApiHelper {
+  constructor(
+    private readonly _cache: SiteBasedCache,
+    private readonly _auth: AuthService,
+    private readonly _fetchPromisesCache: FetchPromisesCache,
+    private readonly _restApiInterface: RestApiService
+  ) {}
+
+  /**
+   * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
+   * with timing and logging.
+s   */
+  fetch(req: FetchRequest): Promise<Response> {
+    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 startTime the time that the request was started.
+   * @param 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.
+   */
+  private _logCall(
+    req: FetchRequest,
+    startTime: number,
+    status: number | null
+  ) {
+    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.info(
+      [
+        'HTTP',
+        status,
+        method,
+        `${elapsed}ms`,
+        req.anonymizedUrl || req.url,
+        `(${startAt.toISOString()}, ${endAt.toISOString()})`,
+      ].join(' ')
+    );
+    if (req.anonymizedUrl) {
+      const detail: RpcLogEventDetail = {
+        status,
+        method,
+        elapsed,
+        anonymizedUrl: req.anonymizedUrl,
+      };
+      this.dispatchEvent(
+        new CustomEvent('rpc-log', {
+          detail,
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
+  }
+
+  /**
+   * 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.
+   *
+   * @return Promise which resolves to undefined if cancelCondition returns true
+   *     and resolves to Response otherwise
+   */
+  fetchRawJSON(req: FetchJSONRequest): Promise<Response | undefined> {
+    const urlWithParams = this.urlWithParams(req.url, req.params);
+    const fetchReq: FetchRequest = {
+      url: urlWithParams,
+      fetchOptions: req.fetchOptions,
+      anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
+    };
+    return this.fetch(fetchReq)
+      .then((res: Response) => {
+        if (req.cancelCondition && req.cancelCondition()) {
+          if (res.body) {
+            res.body.cancel();
+          }
+          return;
+        }
+        return res;
+      })
+      .catch(err => {
+        if (req.errFn) {
+          req.errFn.call(undefined, null, err);
+        } else {
+          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 noAcceptHeader - don't add default accept json header
+   */
+  fetchJSON(
+    req: FetchJSONRequest,
+    noAcceptHeader?: boolean
+  ): Promise<ParsedJSON | undefined> {
+    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 this.getResponseObject(response);
+    });
+  }
+
+  urlWithParams(url: string, fetchParams?: FetchParams): string {
+    if (!fetchParams) {
+      return getBaseUrl() + url;
+    }
+
+    const params: Array<string | number | boolean> = [];
+    for (const p in fetchParams) {
+      if (!hasOwnProperty(fetchParams, p)) {
+        continue;
+      }
+      const paramValue = fetchParams[p];
+      // TODO(TS): Replace == null with === and check for null and undefined
+      // eslint-disable-next-line eqeqeq
+      if (paramValue == null) {
+        params.push(this.encodeRFC5987(p));
+        continue;
+      }
+      // TODO(TS): Unclear, why do we need the following code.
+      // If paramValue can be array - we should either fix FetchParams type
+      // or convert the array to a string before calling urlWithParams method.
+      const paramValueAsArray = ([] as Array<string | number | boolean>).concat(
+        paramValue
+      );
+      for (const value of paramValueAsArray) {
+        params.push(`${this.encodeRFC5987(p)}=${this.encodeRFC5987(value)}`);
+      }
+    }
+    return getBaseUrl() + url + '?' + params.join('&');
+  }
+
+  // Backend encode url in RFC5987 and frontend needs to do same to match
+  // queries for preloading queries
+  encodeRFC5987(uri: string | number | boolean) {
+    return encodeURIComponent(uri).replace(
+      /['()*]/g,
+      c => '%' + c.charCodeAt(0).toString(16)
+    );
+  }
+
+  getResponseObject(response: Response): Promise<ParsedJSON> {
+    return this.readResponsePayload(response).then(payload => payload.parsed);
+  }
+
+  readResponsePayload(response: Response): Promise<ResponsePayload> {
+    return response.text().then(text => {
+      let result;
+      try {
+        result = this.parsePrefixedJSON(text);
+      } catch (_) {
+        result = null;
+      }
+      // TODO(TS): readResponsePayload can assign null to the parsed property if
+      // it can't parse input data. However polygerrit assumes in many places
+      // that the parsed property can't be null. We should update
+      // readResponsePayload method and reject a promise instead of assigning
+      // null to the parsed property
+      return {parsed: result!, raw: text};
+    });
+  }
+
+  parsePrefixedJSON(jsonWithPrefix: string): ParsedJSON {
+    return JSON.parse(
+      jsonWithPrefix.substring(JSON_PREFIX.length)
+    ) as ParsedJSON;
+  }
+
+  addAcceptJsonHeader(req: FetchJSONRequest) {
+    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;
+  }
+
+  dispatchEvent(type: Event, detail?: unknown): boolean {
+    return this._restApiInterface.dispatchEvent(type, detail);
+  }
+
+  fetchCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
+    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)!;
+  }
+
+  // if errFn is not set, then only Response possible
+  send(req: SendRawRequest & {errFn?: undefined}): Promise<Response>;
+
+  send(req: SendRawRequest): Promise<Response | undefined>;
+
+  send(req: SendJSONRequest): Promise<ParsedJSON>;
+
+  send(req: SendRequest): Promise<Response | ParsedJSON | undefined>;
+
+  /**
+   * Send an XHR.
+   *
+   * @return Promise resolves to Response/ParsedJSON only if the request is successful
+   *     (i.e. no exception and response.ok is trsue). If response fails then
+   *     promise resolves either to void if errFn is set or rejects if errFn
+   *     is not set   */
+  send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
+    const options: AuthRequestInit = {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 (!hasOwnProperty(req.headers, header)) {
+          continue;
+        }
+        options.headers.set(header, req.headers[header]);
+      }
+    }
+    const url = req.url.startsWith('http') ? req.url : getBaseUrl() + req.url;
+    const fetchReq: FetchRequest = {
+      url,
+      fetchOptions: options,
+      anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
+    };
+    const xhr = this.fetch(fetchReq)
+      .then(response => {
+        if (!response.ok) {
+          if (req.errFn) {
+            req.errFn.call(undefined, response);
+            return;
+          }
+          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) {
+      // TODO(TS): remove as Response and fix error.
+      // Javascript code allows returning of a Response object from errFn.
+      // This can be a mistake and we should add check here or it can be used
+      // somewhere - in this case we should fix it carefully (define
+      // different type of callback if parseResponse is true, etc...).
+      return xhr.then(res => this.getResponseObject(res as Response));
+    }
+    // The actual xhr type is Promise<Response|undefined|void> because of the
+    // catch callback
+    return xhr as Promise<Response | undefined>;
+  }
+
+  invalidateFetchPromisesPrefix(prefix: string) {
+    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
deleted file mode 100644
index 32d2166..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
+++ /dev/null
@@ -1,174 +0,0 @@
-<!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-rest-api-helper</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 {SiteBasedCache} from './gr-rest-api-helper.js';
-import {FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
-import {authService} from '../gr-auth.js';
-
-suite('gr-rest-api-helper tests', () => {
-  let helper;
-  let sandbox;
-  let cache;
-  let fetchPromisesCache;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    cache = new SiteBasedCache();
-    fetchPromisesCache = new FetchPromisesCache();
-
-    window.CANONICAL_PATH = 'testhelper';
-
-    const mockRestApiInterface = {
-      getBaseUrl: sinon.stub().returns(window.CANONICAL_PATH),
-      fire: sinon.stub(),
-    };
-
-    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');
-    });
-
-    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-rest-apis/gr-rest-api-helper_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
new file mode 100644
index 0000000..4eef8a2f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../../test/common-test-setup-karma.js';
+import {SiteBasedCache} from './gr-rest-api-helper.js';
+import {FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
+import {appContext} from '../../../../services/app-context.js';
+
+suite('gr-rest-api-helper tests', () => {
+  let helper;
+
+  let cache;
+  let fetchPromisesCache;
+  let originalCanonicalPath;
+
+  setup(() => {
+    cache = new SiteBasedCache();
+    fetchPromisesCache = new FetchPromisesCache();
+
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = 'testhelper';
+
+    const mockRestApiInterface = {
+      fire: sinon.stub(),
+    };
+
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sinon.stub(window, 'fetch').returns(Promise.resolve({
+      ok: true,
+      text() {
+        return Promise.resolve(testJSON);
+      },
+    }));
+
+    helper = new GrRestApiHelper(cache, appContext.authService,
+        fetchPromisesCache, mockRestApiInterface);
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  suite('fetchJSON()', () => {
+    test('Sets header to accept application/json', () => {
+      const authFetchStub = sinon.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 = sinon.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',
+      () => helper.fetchJSON({url: '/dummy/url'}).then(obj => {
+        assert.deepEqual(obj, {hello: 'bonjour'});
+      })
+  );
+
+  test('cached results', () => {
+    let n = 0;
+    sinon.stub(helper, 'fetchJSON').callsFake(() => Promise.resolve(++n));
+    const promises = [];
+    promises.push(helper.fetchCacheURL('/foo'));
+    promises.push(helper.fetchCacheURL('/foo'));
+    promises.push(helper.fetchCacheURL('/foo'));
+
+    return Promise.all(promises).then(results => {
+      assert.deepEqual(results, [1, 1, 1]);
+      return helper.fetchCacheURL('/foo').then(foo => {
+        assert.equal(foo, 1);
+      });
+    });
+  });
+
+  test('cached promise', () => {
+    const promise = Promise.reject(new Error('foo'));
+    cache.set('/foo', promise);
+    return helper.fetchCacheURL({url: '/foo'}).catch(p => {
+      assert.equal(p.message, 'foo');
+    });
+  });
+
+  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', () => {
+    let cancelCalled = false;
+    window.fetch.returns(Promise.resolve({
+      body: {
+        cancel() { cancelCalled = true; },
+      },
+    }));
+    const cancelCondition = () => true;
+    return helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(obj => {
+      assert.isUndefined(obj);
+      assert.isTrue(cancelCalled);
+    });
+  });
+});
+
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
deleted file mode 100644
index 3d1ce05..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
+++ /dev/null
@@ -1,229 +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.
- */
-
-import {util} from '../../../scripts/util.js';
-
-/** @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.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);
-    }
-  }
-  if (items.length) {
-    this._batch.updates = items;
-  }
-};
-
-/**
- * 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.toLowerCase() + ': ';
-  } else if (state === 'REMOVED') {
-    if (prev) {
-      return 'Removed from ' + prev.toLowerCase() + ': ';
-    } else {
-      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;
-  }
-};
-
-/**
- * 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.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
new file mode 100644
index 0000000..48a23c6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -0,0 +1,321 @@
+/**
+ * @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 {parseDate} from '../../../utils/date-util';
+import {MessageTag, ReviewerState} from '../../../constants/constants';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ChangeMessageInfo,
+  ChangeViewChangeInfo,
+  CommitInfo,
+  PatchSetNum,
+  ReviewerUpdateInfo,
+  RevisionInfo,
+  Timestamp,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {accountKey} from '../../../utils/account-util';
+
+const MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
+const REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
+
+interface ChangeInfoParserInput extends ChangeViewChangeInfo {
+  messages: ChangeMessageInfo[];
+  reviewer_updates: ReviewerUpdateInfo[]; // Always has at least 1 item
+}
+
+function isChangeInfoParserInput(
+  change: ChangeInfo
+): change is ChangeInfoParserInput {
+  return !!(
+    change.messages &&
+    change.reviewer_updates &&
+    change.reviewer_updates.length
+  );
+}
+
+interface ParserBatch {
+  author: AccountInfo;
+  date: Timestamp;
+  type: 'REVIEWER_UPDATE';
+  tag: MessageTag.TAG_REVIEWER_UPDATE;
+  updates?: UpdateItem[];
+}
+
+interface ParserBatchWithNonEmptyUpdates extends ParserBatch {
+  updates: UpdateItem[]; // Always has at least 1 items
+}
+
+export interface FormattedReviewerUpdateInfo {
+  author: AccountInfo;
+  date: Timestamp;
+  type: 'REVIEWER_UPDATE';
+  tag: MessageTag.TAG_REVIEWER_UPDATE;
+  updates: {message: string; reviewers: AccountInfo[]}[];
+}
+
+function isParserBatchWithNonEmptyUpdates(
+  x: ParserBatch
+): x is ParserBatchWithNonEmptyUpdates {
+  return !!(x.updates && x.updates.length);
+}
+
+interface UpdateItem {
+  reviewer: AccountInfo;
+  state: ReviewerState;
+  prev_state?: ReviewerState;
+}
+
+export interface EditRevisionInfo extends Partial<RevisionInfo> {
+  // EditRevisionInfo has less required properties then RevisionInfo
+  _number: PatchSetNum;
+  basePatchNum: PatchSetNum;
+  commit: CommitInfo;
+}
+
+export interface ParsedChangeInfo
+  extends Omit<ChangeViewChangeInfo, 'reviewer_updates' | 'revisions'> {
+  revisions: {[revisionId: string]: RevisionInfo | EditRevisionInfo};
+  reviewer_updates?: ReviewerUpdateInfo[] | FormattedReviewerUpdateInfo[];
+}
+
+type ReviewersGroupByMessage = {[message: string]: AccountInfo[]};
+
+export class GrReviewerUpdatesParser {
+  // TODO(TS): The parser several times reassigns different types to
+  // reviewer_updates. After parse complete, the result has ParsedChangeInfo
+  // type. This class should be refactored to avoid reassignment.
+  private readonly result: ChangeInfoParserInput;
+
+  private _batch: ParserBatch | null = null;
+
+  private _updateItems: {[accountId: string]: UpdateItem} | null = null;
+
+  private readonly _lastState: {[accountId: string]: ReviewerState} = {};
+
+  constructor(change: ChangeInfoParserInput) {
+    this.result = {...change};
+  }
+
+  /**
+   * Removes messages that describe removed reviewers, since reviewer_updates
+   * are used.
+   */
+  private _filterRemovedMessages() {
+    this.result.messages = this.result.messages.filter(
+      message => message.tag !== MessageTag.TAG_DELETE_REVIEWER
+    );
+  }
+
+  /**
+   * Is a part of _groupUpdates(). Creates a new batch of updates.
+   */
+  private _startBatch(update: ReviewerUpdateInfo): ParserBatch {
+    this._updateItems = {};
+    return {
+      author: update.updated_by,
+      date: update.updated,
+      type: 'REVIEWER_UPDATE',
+      tag: MessageTag.TAG_REVIEWER_UPDATE,
+    };
+  }
+
+  /**
+   * Is a part of _groupUpdates(). Validates current batch:
+   * - filters out updates that don't change reviewer state.
+   * - updates current reviewer state.
+   */
+  private _completeBatch(batch: ParserBatch) {
+    const items = [];
+    for (const accountId in this._updateItems) {
+      if (!hasOwnProperty(this._updateItems, accountId)) continue;
+      const updateItem = this._updateItems[accountId];
+      if (this._lastState[accountId] !== updateItem.state) {
+        this._lastState[accountId] = updateItem.state;
+        items.push(updateItem);
+      }
+    }
+    if (items.length) {
+      batch.updates = items;
+    }
+  }
+
+  /**
+   * 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)
+   */
+  _groupUpdates(): ParserBatchWithNonEmptyUpdates[] {
+    const updates = this.result.reviewer_updates;
+    const newUpdates = updates.reduce((newUpdates, update) => {
+      if (!this._batch) {
+        this._batch = this._startBatch(update);
+      }
+      const updateDate = parseDate(update.updated).getTime();
+      const batchUpdateDate = parseDate(this._batch.date).getTime();
+      const reviewerId = accountKey(update.reviewer);
+      if (
+        updateDate - batchUpdateDate > REVIEWER_UPDATE_THRESHOLD_MILLIS ||
+        update.updated_by._account_id !== this._batch.author._account_id
+      ) {
+        // Next sequential update should form new group.
+        this._completeBatch(this._batch);
+        if (isParserBatchWithNonEmptyUpdates(this._batch)) {
+          newUpdates.push(this._batch);
+        }
+        this._batch = this._startBatch(update);
+      }
+      // _startBatch assigns _updateItems. When _groupUpdates is calling,
+      // _batch and _updateItems are not set => _startBatch is called. The
+      // _startBatch method assigns _updateItems
+      const updateItems = this._updateItems!;
+      updateItems[reviewerId] = {
+        reviewer: update.reviewer,
+        state: update.state,
+      };
+      if (this._lastState[reviewerId]) {
+        updateItems[reviewerId].prev_state = this._lastState[reviewerId];
+      }
+      return newUpdates;
+    }, [] as ParserBatchWithNonEmptyUpdates[]);
+    // reviewer_updates always has at least 1 item
+    // (otherwise parse is not created) => updates.reduce calls callback
+    // at least once and callback assigns this._batch
+    const batch = this._batch!;
+    this._completeBatch(batch);
+    if (isParserBatchWithNonEmptyUpdates(batch)) {
+      newUpdates.push(batch);
+    }
+    ((this.result
+      .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[]) = newUpdates;
+    return newUpdates;
+  }
+
+  /**
+   * Generates update message for reviewer state change.
+   */
+  private _getUpdateMessage(
+    prevReviewerState: string | undefined,
+    currentReviewerState: string
+  ): string {
+    if (prevReviewerState === 'REMOVED' || !prevReviewerState) {
+      return `Added to ${currentReviewerState.toLowerCase()}: `;
+    } else if (currentReviewerState === 'REMOVED') {
+      if (prevReviewerState) {
+        return `Removed from ${prevReviewerState.toLowerCase()}: `;
+      } else {
+        return 'Removed : ';
+      }
+    } else {
+      return `Moved from ${prevReviewerState.toLowerCase()} to ${currentReviewerState.toLowerCase()}: `;
+    }
+  }
+
+  /**
+   * Groups updates for same category (eg CC->CC) into a hash arrays of
+   * reviewers.
+   */
+  _groupUpdatesByMessage(updates: UpdateItem[]): ReviewersGroupByMessage {
+    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;
+    }, {} as ReviewersGroupByMessage);
+  }
+
+  /**
+   * 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/
+   */
+  _formatUpdates() {
+    const reviewerUpdates = (this.result
+      .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[];
+    for (const update of reviewerUpdates) {
+      const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      const newUpdates: {message: string; reviewers: AccountInfo[]}[] = [];
+      for (const message in grouppedReviewers) {
+        if (hasOwnProperty(grouppedReviewers, message)) {
+          newUpdates.push({
+            message,
+            reviewers: grouppedReviewers[message],
+          });
+        }
+      }
+      ((update as unknown) as FormattedReviewerUpdateInfo).updates = newUpdates;
+    }
+  }
+
+  /**
+   * 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.
+   */
+  _advanceUpdates() {
+    const updates = (this.result
+      .reviewer_updates as unknown) as FormattedReviewerUpdateInfo[];
+    const messages = this.result.messages;
+    messages.forEach((message, index) => {
+      const messageDate = parseDate(message.date).getTime();
+      const nextMessageDate =
+        index === messages.length - 1
+          ? null
+          : parseDate(messages[index + 1].date).getTime();
+      for (const update of updates) {
+        const date = parseDate(update.date).getTime();
+        if (
+          date >= messageDate &&
+          (!nextMessageDate || date < nextMessageDate)
+        ) {
+          const timestamp =
+            parseDate(update.date).getTime() -
+            MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
+          update.date = new Date(timestamp)
+            .toISOString()
+            .replace('T', ' ')
+            .replace('Z', '000000') as Timestamp;
+        }
+        if (nextMessageDate && date > nextMessageDate) {
+          break;
+        }
+      }
+    });
+  }
+
+  static parse(
+    change: ChangeViewChangeInfo | undefined | null
+  ): ParsedChangeInfo | undefined | null {
+    // TODO(TS): The !change condition should be removed when all files are converted to TS
+    if (!change || !isChangeInfoParserInput(change)) {
+      return change;
+    }
+
+    const parser = new GrReviewerUpdatesParser(change);
+    parser._filterRemovedMessages();
+    parser._groupUpdates();
+    parser._formatUpdates();
+    parser._advanceUpdates();
+    return parser.result;
+  }
+}
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
deleted file mode 100644
index f2ccfb7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
+++ /dev/null
@@ -1,306 +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">
-<meta charset="utf-8">
-<title>gr-reviewer-updates-parser</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 {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
-import {util} from '../../../scripts/util.js';
-
-suite('gr-reviewer-updates-parser tests', () => {
-  let sandbox;
-  let instance;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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 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 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',
-        },
-      ],
-    };
-    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/gr-reviewer-updates-parser_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
new file mode 100644
index 0000000..34fb709
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
@@ -0,0 +1,287 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
+import {parseDate} from '../../../utils/date-util.js';
+
+suite('gr-reviewer-updates-parser tests', () => {
+  let instance;
+
+  test('ignores changes without messages', () => {
+    const change = {};
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sinon.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: [],
+    };
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sinon.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: [],
+    };
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sinon.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: [{
+        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 = 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(parseDate(updates[0].date).getTime(), T0);
+    assert.isBelow(parseDate(updates[1].date).getTime(), T0);
+    assert.equal(updates[2].date, tplus(100));
+    assert.equal(updates[3].date, tplus(500));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
deleted file mode 100644
index e061e93..0000000
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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';
-const $_documentContainer = document.createElement('template');
-
-$_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',
-      },
-    };
-  }
-
-  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');
-  }
-
-  _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;
-      }, 1);
-    }
-  }
-
-  _valueChanged() {
-    this.bindValue = this.nativeSelect.value;
-  }
-
-  focus() {
-    this.nativeSelect.focus();
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('change',
-        () => this._valueChanged());
-    this.addEventListener('dom-change',
-        () => this._updateValue());
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    // If not set via the property, set bind-value to the element value.
-    if (this.bindValue == undefined && this.nativeSelect.options.length > 0) {
-      this.bindValue = this.nativeSelect.value;
-    }
-  }
-}
-
-customElements.define(GrSelect.is, GrSelect);
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
new file mode 100644
index 0000000..a2c1253
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -0,0 +1,91 @@
+/**
+ * @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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {customElement, property, observe} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-select': GrSelect;
+  }
+}
+
+/**
+ * GrSelect `gr-select` component.
+ */
+@customElement('gr-select')
+export class GrSelect extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return html` <slot></slot> `;
+  }
+
+  @property({type: String, notify: true})
+  bindValue?: string;
+
+  get nativeSelect() {
+    // gr-select is not a shadow component
+    // TODO(taoalpha): maybe we should convert
+    // it into a shadow dom component instead
+    // TODO(TS): should warn if no `select` detected.
+    return this.querySelector('select')!;
+  }
+
+  @observe('bindValue')
+  _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(() => {
+        // TODO(TS): maybe should check for undefined before assigning
+        // or fallback to ''
+        this.nativeSelect.value = this.bindValue!;
+      }, 1);
+    }
+  }
+
+  _valueChanged() {
+    this.bindValue = this.nativeSelect.value;
+  }
+
+  focus() {
+    this.nativeSelect.focus();
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('change', () => this._valueChanged());
+    this.addEventListener('dom-change', () => this._updateValue());
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    // If not set via the property, set bind-value to the element value.
+    if (this.bindValue === undefined && this.nativeSelect.options.length > 0) {
+      this.bindValue = this.nativeSelect.value;
+    }
+  }
+}
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
deleted file mode 100644
index 670f383..0000000
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ /dev/null
@@ -1,120 +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">
-<meta charset="utf-8">
-<title>gr-select</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-select>
-      <select>
-        <option value="1">One</option>
-        <option value="2">Two</option>
-        <option value="3">Three</option>
-      </select>
-    </gr-select>
-  </template>
-</test-fixture>
-
-<test-fixture id="noOptions">
-  <template>
-    <gr-select>
-      <select>
-      </select>
-    </gr-select>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-select.js';
-suite('gr-select tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  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('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);
-
-    // 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);
-  });
-
-  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);
-
-    // 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);
-  });
-
-  suite('gr-select no options tests', () => {
-    let element;
-
-    setup(() => {
-      element = fixture('noOptions');
-    });
-
-    test('bindValue must not be changed', () => {
-      assert.isUndefined(element.bindValue);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
new file mode 100644
index 0000000..c697850
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-select.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-select>
+      <select>
+        <option value="1">One</option>
+        <option value="2">Two</option>
+        <option value="3">Three</option>
+      </select>
+    </gr-select>
+`);
+
+const noOptionsFixture = fixtureFromTemplate(html`
+<gr-select>
+      <select>
+      </select>
+    </gr-select>
+`);
+
+suite('gr-select tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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('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);
+
+    // 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);
+  });
+
+  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);
+
+    // 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);
+  });
+
+  suite('gr-select no options tests', () => {
+    let element;
+
+    setup(() => {
+      element = noOptionsFixture.instantiate();
+    });
+
+    test('bindValue must not be changed', () => {
+      assert.isUndefined(element.bindValue);
+    });
+  });
+});
+
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
deleted file mode 100644
index 151498c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
+++ /dev/null
@@ -1,46 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/** @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.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.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
new file mode 100644
index 0000000..27f4069
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -0,0 +1,53 @@
+/**
+ * @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.
+ */
+import '../../../styles/shared-styles';
+import '../gr-copy-clipboard/gr-copy-clipboard';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-shell-command_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-shell-command': GrShellCommand;
+  }
+}
+
+@customElement('gr-shell-command')
+class GrShellCommand extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  command: string | undefined;
+
+  @property({type: String})
+  label: string | undefined;
+
+  focusOnCopy() {
+    if (this.shadowRoot !== null) {
+      const copyClipboard = this.shadowRoot.querySelector('gr-copy-clipboard');
+      if (copyClipboard !== null) {
+        copyClipboard.focusOnCopy();
+      }
+    }
+  }
+}
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
deleted file mode 100644
index 4a4480e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
new file mode 100644
index 0000000..ef76999
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
@@ -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';
+
+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
deleted file mode 100644
index ee0b64f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
+++ /dev/null
@@ -1,61 +0,0 @@
-<!DOCTYPE html>
-<!--
-@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.
--->
-
-<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="/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-shell-command></gr-shell-command>
-  </template>
-</test-fixture>
-
-<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.shadowRoot
-        .querySelector('gr-copy-clipboard'),
-    'focusOnCopy');
-    element.focusOnCopy();
-    assert.isTrue(focusStub.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
new file mode 100644
index 0000000..de9f243
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
@@ -0,0 +1,41 @@
+/**
+ * @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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-shell-command.js';
+
+const basicFixture = fixtureFromElement('gr-shell-command');
+
+suite('gr-shell-command tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+    flush();
+  });
+
+  test('focusOnCopy', () => {
+    const focusStub = sinon.stub(element.shadowRoot
+        .querySelector('gr-copy-clipboard'),
+    'focusOnCopy');
+    element.focusOnCopy();
+    assert.isTrue(focusStub.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
deleted file mode 100644
index 8f5c486..0000000
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ /dev/null
@@ -1,163 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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';
-
-const DURATION_DAY = 24 * 60 * 60 * 1000;
-
-// Clean up old entries no more frequently than one day.
-const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
-
-const CLEANUP_PREFIXES_MAX_AGE_MAP = {
-  // respectfultip has a 14-day expiration
-  'respectfultip:': 14 * DURATION_DAY,
-  'draft:': DURATION_DAY,
-  'editablecontent:': DURATION_DAY,
-};
-
-/** @extends Polymer.Element */
-class GrStorage extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get is() { return 'gr-storage'; }
-
-  static get properties() {
-    return {
-      _lastCleanup: Number,
-      /** @type {?Storage} */
-      _storage: {
-        type: Object,
-        value() {
-          return window.localStorage;
-        },
-      },
-      _exceededQuota: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  getDraftComment(location) {
-    this._cleanupItems();
-    return this._getObject(this._getDraftKey(location));
-  }
-
-  setDraftComment(location, message) {
-    const key = this._getDraftKey(location);
-    this._setObject(key, {message, updated: Date.now()});
-  }
-
-  eraseDraftComment(location) {
-    const key = this._getDraftKey(location);
-    this._storage.removeItem(key);
-  }
-
-  getEditableContentItem(key) {
-    this._cleanupItems();
-    return this._getObject(this._getEditableContentKey(key));
-  }
-
-  setEditableContentItem(key, message) {
-    this._setObject(this._getEditableContentKey(key),
-        {message, updated: Date.now()});
-  }
-
-  getRespectfulTipVisibility() {
-    this._cleanupItems();
-    return this._getObject('respectfultip:visibility');
-  }
-
-  setRespectfulTipVisibility(delayDays = 0) {
-    this._cleanupItems();
-    this._setObject(
-        'respectfultip:visibility',
-        {updated: Date.now() + delayDays * DURATION_DAY}
-    );
-  }
-
-  eraseEditableContentItem(key) {
-    this._storage.removeItem(this._getEditableContentKey(key));
-  }
-
-  _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;
-  }
-
-  _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);
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
new file mode 100644
index 0000000..1369a17
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
@@ -0,0 +1,183 @@
+/**
+ * @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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+import {CommentRange, PatchSetNum} from '../../../types/common';
+
+export interface StorageLocation {
+  changeNum: number;
+  patchNum: PatchSetNum | '@change';
+  path?: string;
+  line?: number;
+  range?: CommentRange;
+}
+
+export interface StorageObject {
+  message?: string;
+  updated: number;
+}
+
+const DURATION_DAY = 24 * 60 * 60 * 1000;
+
+// Clean up old entries no more frequently than one day.
+const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
+
+const CLEANUP_PREFIXES_MAX_AGE_MAP = new Map<string, number>();
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('respectfultip', 14 * DURATION_DAY);
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-storage': GrStorage;
+  }
+}
+
+export interface GrStorage {
+  $: {};
+}
+
+@customElement('gr-storage')
+export class GrStorage extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  @property({type: Number})
+  _lastCleanup = 0;
+
+  @property({type: Object})
+  _storage = window.localStorage;
+
+  @property({type: Boolean})
+  _exceededQuota = false;
+
+  getDraftComment(location: StorageLocation): StorageObject | null {
+    this._cleanupItems();
+    return this._getObject(this._getDraftKey(location));
+  }
+
+  setDraftComment(location: StorageLocation, message: string) {
+    const key = this._getDraftKey(location);
+    this._setObject(key, {message, updated: Date.now()});
+  }
+
+  eraseDraftComment(location: StorageLocation) {
+    const key = this._getDraftKey(location);
+    this._storage.removeItem(key);
+  }
+
+  getEditableContentItem(key: string): StorageObject | null {
+    this._cleanupItems();
+    return this._getObject(this._getEditableContentKey(key));
+  }
+
+  setEditableContentItem(key: string, message: string) {
+    this._setObject(this._getEditableContentKey(key), {
+      message,
+      updated: Date.now(),
+    });
+  }
+
+  getRespectfulTipVisibility(): StorageObject | null {
+    this._cleanupItems();
+    return this._getObject('respectfultip:visibility');
+  }
+
+  setRespectfulTipVisibility(delayDays = 0) {
+    this._cleanupItems();
+    this._setObject('respectfultip:visibility', {
+      updated: Date.now() + delayDays * DURATION_DAY,
+    });
+  }
+
+  eraseEditableContentItem(key: string) {
+    this._storage.removeItem(this._getEditableContentKey(key));
+  }
+
+  _getDraftKey(location: StorageLocation): string {
+    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;
+  }
+
+  _getEditableContentKey(key: string): string {
+    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();
+
+    Object.keys(this._storage).forEach(key => {
+      const entries = CLEANUP_PREFIXES_MAX_AGE_MAP.entries();
+      for (const [prefix, expiration] of entries) {
+        if (key.startsWith(prefix)) {
+          const item = this._getObject(key);
+          if (!item || Date.now() - item.updated > expiration) {
+            this._storage.removeItem(key);
+          }
+        }
+      }
+    });
+  }
+
+  _getObject(key: string): StorageObject | null {
+    const serial = this._storage.getItem(key);
+    if (!serial) {
+      return null;
+    }
+    return JSON.parse(serial) as StorageObject;
+  }
+
+  _setObject(key: string, obj: StorageObject) {
+    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;
+      }
+    }
+  }
+}
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
deleted file mode 100644
index b560c56..0000000
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ /dev/null
@@ -1,195 +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">
-<meta charset="utf-8">
-<title>gr-storage</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-storage></gr-storage>
-  </template>
-</test-fixture>
-
-<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;
-      },
-    };
-  }
-
-  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-storage/gr-storage_test.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
new file mode 100644
index 0000000..99f953f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
@@ -0,0 +1,179 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-storage.js';
+
+const basicFixture = fixtureFromElement('gr-storage');
+
+suite('gr-storage tests', () => {
+  let element;
+
+  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 = basicFixture.instantiate();
+
+    element._storage = mockStorage();
+  });
+
+  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 = sinon.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 = sinon.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));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
deleted file mode 100644
index 15ab8e4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ /dev/null
@@ -1,347 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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 MAX_ITEMS_DROPDOWN = 10;
-
-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 ;)'},
-];
-
-/**
- * @extends Polymer.Element
- */
-class GrTextarea extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-textarea'; }
-  /**
-   * @event bind-value-changed
-   */
-
-  static get properties() {
-    return {
-      autocomplete: Boolean,
-      disabled: Boolean,
-      rows: Number,
-      maxRows: Number,
-      placeholder: String,
-      text: {
-        type: String,
-        notify: true,
-        observer: '_handleTextChanged',
-      },
-      hideBorder: {
-        type: Boolean,
-        value: false,
-      },
-      /** Text input should be rendered in monspace font.  */
-      monospace: {
-        type: Boolean,
-        value: false,
-      },
-      /** Text input should be rendered in code font, which is smaller than the
-        standard monospace font. */
-      code: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?number} */
-      _colonIndex: Number,
-      _currentSearchString: {
-        type: String,
-        observer: '_determineSuggestions',
-      },
-      _hideAutocomplete: {
-        type: Boolean,
-        value: true,
-      },
-      _index: Number,
-      _suggestions: Array,
-      // Offset makes dropdown appear below text.
-      _verticalOffset: {
-        type: Number,
-        value: 20,
-        readOnly: true,
-      },
-    };
-  }
-
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-      tab: '_handleEnterByKey',
-      enter: '_handleEnterByKey',
-      up: '_handleUpKey',
-      down: '_handleDownKey',
-    };
-  }
-
-  /** @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;
-      }
-    }
-
-    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 => suggestion.match.includes(emojiText))
-          .slice(0, MAX_ITEMS_DROPDOWN);
-      this._formatSuggestions(matches);
-      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();
-  }
-
-  _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.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
new file mode 100644
index 0000000..b3c0a85
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -0,0 +1,419 @@
+/**
+ * @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 '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import '../gr-cursor-manager/gr-cursor-manager';
+import '../gr-overlay/gr-overlay';
+import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-textarea_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+
+const MAX_ITEMS_DROPDOWN = 10;
+
+const ALL_SUGGESTIONS: EmojiSuggestion[] = [
+  {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 ;)'},
+];
+
+interface EmojiSuggestion {
+  value: string;
+  match: string;
+  dataValue?: string;
+  text?: string;
+}
+
+interface ValueChangeEvent {
+  value: string;
+}
+
+export interface GrTextarea {
+  $: {
+    textarea: IronAutogrowTextareaElement;
+    emojiSuggestions: GrAutocompleteDropdown;
+    caratSpan: HTMLSpanElement;
+    hiddenText: HTMLDivElement;
+  };
+}
+/**
+ * @extends PolymerElement
+ */
+@customElement('gr-textarea')
+export class GrTextarea extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * @event bind-value-changed
+   */
+  @property({type: Boolean})
+  autocomplete?: boolean;
+
+  @property({type: Boolean})
+  disabled?: boolean;
+
+  @property({type: Number})
+  rows?: number;
+
+  @property({type: Number})
+  maxRows?: number;
+
+  @property({type: String})
+  placeholder?: string;
+
+  @property({type: String, notify: true, observer: '_handleTextChanged'})
+  text?: string;
+
+  @property({type: Boolean})
+  hideBorder = false;
+
+  /** Text input should be rendered in monspace font.  */
+  @property({type: Boolean})
+  monospace = false;
+
+  /** Text input should be rendered in code font, which is smaller than the
+    standard monospace font. */
+  @property({type: Boolean})
+  code = false;
+
+  @property({type: Number})
+  _colonIndex: number | null = null;
+
+  @property({type: String, observer: '_determineSuggestions'})
+  _currentSearchString?: string;
+
+  @property({type: Boolean})
+  _hideAutocomplete = true;
+
+  @property({type: Number})
+  _index?: number;
+
+  @property({type: Array})
+  _suggestions?: EmojiSuggestion[];
+
+  @property({type: Number})
+  readonly _verticalOffset = 20;
+  // Offset makes dropdown appear below text.
+
+  reporting: ReportingService;
+
+  disableEnterKeyForSelectingEmoji = false;
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+      tab: '_handleEnterByKey',
+      enter: '_handleEnterByKey',
+      up: '_handleUpKey',
+      down: '_handleDownKey',
+    };
+  }
+
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
+  /** @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: KeyboardEvent) {
+    if (this._hideAutocomplete) {
+      return;
+    }
+    e.preventDefault();
+    e.stopPropagation();
+    this._resetEmojiDropdown();
+  }
+
+  _handleUpKey(e: KeyboardEvent) {
+    if (this._hideAutocomplete) {
+      return;
+    }
+    e.preventDefault();
+    e.stopPropagation();
+    this.$.emojiSuggestions.cursorUp();
+    this.$.textarea.textarea.focus();
+    this.disableEnterKeyForSelectingEmoji = false;
+  }
+
+  _handleDownKey(e: KeyboardEvent) {
+    if (this._hideAutocomplete) {
+      return;
+    }
+    e.preventDefault();
+    e.stopPropagation();
+    this.$.emojiSuggestions.cursorDown();
+    this.$.textarea.textarea.focus();
+    this.disableEnterKeyForSelectingEmoji = false;
+  }
+
+  _handleEnterByKey(e: KeyboardEvent) {
+    if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+      return;
+    }
+    e.preventDefault();
+    e.stopPropagation();
+    this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+  }
+
+  _handleEmojiSelect(e: CustomEvent) {
+    this._setEmoji(e.detail.selected.dataset['value']);
+  }
+
+  _setEmoji(text: string) {
+    if (this._colonIndex === null) {
+      return;
+    }
+    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: string) {
+    if (!this.text) return '';
+    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;
+    if (typeof this.$.textarea.value === 'string') {
+      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 Number(fontSizePx.substr(0, fontSizePx.length - 2));
+  }
+
+  _getScrollTop() {
+    return document.body.scrollTop;
+  }
+
+  /**
+   * _handleKeydown used for key handling in the this.$.textarea AND all child
+   * autocomplete options.
+   */
+  _onValueChanged(e: CustomEvent<ValueChangeEvent>) {
+    // 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 === null ||
+      !(e.currentTarget as IronAutogrowTextareaElement).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._colonIndex === null) {
+      return;
+    }
+
+    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 ||
+      !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: EmojiSuggestion[]) {
+    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: string) {
+    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;
+    }
+  }
+
+  _resetEmojiDropdown() {
+    // hide and reset the autocomplete dropdown.
+    flush();
+    this._currentSearchString = '';
+    this._hideAutocomplete = true;
+    this.closeDropdown();
+    this._colonIndex = null;
+    this.$.textarea.textarea.focus();
+  }
+
+  _handleTextChanged(text: string) {
+    this.dispatchEvent(
+      new CustomEvent('value-changed', {detail: {value: text}})
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-textarea': 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
deleted file mode 100644
index 61e530a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
new file mode 100644
index 0000000..1f777aa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
@@ -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.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+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>
+`;
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
deleted file mode 100644
index c33b2ae..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
+++ /dev/null
@@ -1,378 +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">
-<meta charset="utf-8">
-<title>gr-textarea</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-textarea></gr-textarea>
-  </template>
-</test-fixture>
-
-<test-fixture id="monospace">
-  <template>
-    <gr-textarea monospace="true"></gr-textarea>
-  </template>
-</test-fixture>
-
-<test-fixture id="hideBorder">
-  <template>
-    <gr-textarea hide-border="true"></gr-textarea>
-  </template>
-</test-fixture>
-
-<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');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('monospace is set properly', () => {
-    assert.isFalse(element.classList.contains('monospace'));
-  });
-
-  test('hideBorder is set properly', () => {
-    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
-  });
-
-  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 = ':';
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 2;
-      element.text = ':1';
-      flushAsynchronousOperations();
-    }
-
-    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('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);
-    });
-  });
-
-  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.
-
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('monospace');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('monospace is set properly', () => {
-      assert.isTrue(element.classList.contains('monospace'));
-    });
-  });
-
-  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.
-
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('hideBorder');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('hideBorder is set properly', () => {
-      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
new file mode 100644
index 0000000..5b3a2b2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
@@ -0,0 +1,343 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-textarea.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromElement('gr-textarea');
+
+const monospaceFixture = fixtureFromTemplate(html`
+<gr-textarea monospace="true"></gr-textarea>
+`);
+
+const hideBorderFixture = fixtureFromTemplate(html`
+<gr-textarea hide-border="true"></gr-textarea>
+`);
+
+suite('gr-textarea tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    sinon.stub(element.reporting, 'reportInteraction');
+  });
+
+  test('monospace is set properly', () => {
+    assert.isFalse(element.classList.contains('monospace'));
+  });
+
+  test('hideBorder is set properly', () => {
+    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+  });
+
+  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 = ':';
+        flush();
+        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 = ' :';
+        flush();
+        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:';
+        flush();
+        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';
+        flush();
+        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';
+        sinon.stub(element.$, 'textarea').value( {
+          selectionStart: 1,
+          value: text,
+          textarea: {
+            focus: () => {},
+          },
+        });
+        element.text = text;
+        flush();
+        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 = sinon.stub(element, '_resetEmojiDropdown');
+    MockInteractions.focus(element.$.textarea);
+    flush();
+    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 = sinon.spy(element, 'closeDropdown');
+    element._resetEmojiDropdown();
+    assert.equal(element._currentSearchString, '');
+    assert.isTrue(element._hideAutocomplete);
+    assert.equal(element._colonIndex, null);
+
+    element.$.emojiSuggestions.open();
+    flush();
+    element._resetEmojiDropdown();
+    assert.isTrue(closeSpy.called);
+  });
+
+  test('_determineSuggestions', () => {
+    const emojiText = 'tear';
+    const formatSpy = sinon.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 = sinon.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 = ':';
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 2;
+      element.text = ':1';
+      flush();
+    }
+
+    test('escape key', () => {
+      const resetSpy = sinon.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('up key', () => {
+      const upSpy = sinon.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 = sinon.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 = sinon.spy(element.$.emojiSuggestions,
+          'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isTrue(enterSpy.called);
+      flush();
+      assert.equal(element.text, '💯');
+    });
+
+    test('enter key - ignored on just colon without more information', () => {
+      const enterSpy = sinon.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 = ':';
+      flush();
+      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.
+
+    let element;
+
+    setup(() => {
+      element = monospaceFixture.instantiate();
+    });
+
+    test('monospace is set properly', () => {
+      assert.isTrue(element.classList.contains('monospace'));
+    });
+  });
+
+  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.
+
+    let element;
+
+    setup(() => {
+      element = hideBorderFixture.instantiate();
+    });
+
+    test('hideBorder is set properly', () => {
+      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
+    });
+  });
+});
+
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
deleted file mode 100644
index 160f50a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
+++ /dev/null
@@ -1,53 +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.
- */
-import '../../../scripts/bundled-polymer.js';
-
-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';
-
-/**
- * @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,
-      },
-      showIcon: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-}
-
-customElements.define(GrTooltipContent.is, GrTooltipContent);
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
new file mode 100644
index 0000000..cfd9e81
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -0,0 +1,47 @@
+/**
+ * @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 '../gr-icons/gr-icons';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-tooltip-content_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-tooltip-content': GrTooltipContent;
+  }
+}
+
+/**
+ * Transclude anything inside and wrap them to support tooltip functionality.
+ */
+@customElement('gr-tooltip-content')
+export class GrTooltipContent extends TooltipMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, reflectToAttribute: true})
+  maxWidth?: string;
+
+  @property({type: Boolean})
+  showIcon = false;
+}
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
deleted file mode 100644
index e5a2813..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts
new file mode 100644
index 0000000..952420d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts
@@ -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';
+
+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
deleted file mode 100644
index a8fc18a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
+++ /dev/null
@@ -1,61 +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">
-<meta charset="utf-8">
-<title>gr-storage</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-tooltip-content>
-    </gr-tooltip-content>
-  </template>
-</test-fixture>
-
-<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-content/gr-tooltip-content_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
new file mode 100644
index 0000000..f905eaa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
@@ -0,0 +1,51 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-tooltip-content.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-tooltip-content>
+    </gr-tooltip-content>
+`);
+
+suite('gr-tooltip-content tests', () => {
+  let element;
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
deleted file mode 100644
index 0cd2d7c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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-tooltip_html.js';
-
-/** @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,
-        observer: '_updateWidth',
-      },
-      positionBelow: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-    };
-  }
-
-  _updateWidth(maxWidth) {
-    this.updateStyles({'--tooltip-max-width': maxWidth});
-  }
-}
-
-customElements.define(GrTooltip.is, GrTooltip);
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
new file mode 100644
index 0000000..c1a8eb2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -0,0 +1,55 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-tooltip_html';
+import {customElement, property, observe} from '@polymer/decorators';
+
+export interface GrTooltip {
+  $: {};
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-tooltip': GrTooltip;
+  }
+}
+
+@customElement('gr-tooltip')
+export class GrTooltip extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  text = '';
+
+  @property({type: String})
+  maxWidth = '';
+
+  @property({type: Boolean, reflectToAttribute: true})
+  positionBelow = false;
+
+  @observe('maxWidth')
+  _updateWidth(maxWidth: string) {
+    this.updateStyles({'--tooltip-max-width': maxWidth});
+  }
+}
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
deleted file mode 100644
index 3f02fc5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.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_html.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts
new file mode 100644
index 0000000..d59a6c3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts
@@ -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';
+
+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
deleted file mode 100644
index b69d945..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
+++ /dev/null
@@ -1,66 +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">
-<meta charset="utf-8">
-<title>gr-storage</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-tooltip>
-    </gr-tooltip>
-  </template>
-</test-fixture>
-
-<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/gr-tooltip/gr-tooltip_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js
new file mode 100644
index 0000000..65a442b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js
@@ -0,0 +1,57 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-tooltip.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-tooltip>
+    </gr-tooltip>
+`);
+
+suite('gr-tooltip tests', () => {
+  let element;
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await flush();
+  });
+
+  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');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.js b/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
deleted file mode 100644
index 3d9c2bc..0000000
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
+++ /dev/null
@@ -1,81 +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.
- */
-
-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.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
new file mode 100644
index 0000000..fadbfa7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -0,0 +1,80 @@
+/**
+ * @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 {patchNumEquals} from '../../../utils/patch-set-util';
+import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
+
+type RevNumberToParentCountMap = {[revNumber: number]: number};
+
+export class RevisionInfo {
+  /**
+   * @constructor
+   * @param change A change object resulting from a change detail
+   *     call that includes revision information.
+   */
+  constructor(private change: ChangeInfo | ParsedChangeInfo) {}
+
+  /**
+   * 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.
+   */
+  getMaxParents() {
+    if (!this.change || !this.change.revisions) {
+      return 0;
+    }
+    return Object.values(this.change.revisions).reduce(
+      (acc, rev) => Math.max(!rev.commit ? 0 : rev.commit.parents.length, acc),
+      0
+    );
+  }
+
+  /**
+   * Get an object that maps revision numbers to the number of parents of the
+   * commit of that revision.
+   */
+  getParentCountMap() {
+    const result: RevNumberToParentCountMap = {};
+    if (!this.change || !this.change.revisions) {
+      return {};
+    }
+    Object.values(this.change.revisions).forEach(rev => {
+      if (rev.commit) result[rev._number as number] = rev.commit.parents.length;
+    });
+    return result;
+  }
+
+  getParentCount(patchNum: PatchSetNum) {
+    return this.getParentCountMap()[patchNum as number];
+  }
+
+  /**
+   * Get the commit ID of the (0-offset) indexed parent in the given revision
+   * number.
+   */
+
+  getParentId(patchNum: PatchSetNum, parentIndex: number) {
+    if (!this.change.revisions) return;
+    const rev = Object.values(this.change.revisions).find(rev =>
+      patchNumEquals(rev._number, patchNum)
+    );
+    if (!rev || !rev.commit) return;
+    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
deleted file mode 100644
index 2d89b30..0000000
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
+++ /dev/null
@@ -1,90 +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">
-<meta charset="utf-8">
-<title>revision-info</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 './revision-info.js';
-import {RevisionInfo} from './revision-info.js';
-suite('revision-info tests', () => {
-  let mockChange;
-
-  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/elements/shared/revision-info/revision-info_test.js b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js
new file mode 100644
index 0000000..7d0dd4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js
@@ -0,0 +1,79 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './revision-info.js';
+import {RevisionInfo} from './revision-info.js';
+suite('revision-info tests', () => {
+  let mockChange;
+
+  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');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/test/plugin.html b/polygerrit-ui/app/elements/test/plugin.html
deleted file mode 100644
index ecd9007..0000000
--- a/polygerrit-ui/app/elements/test/plugin.html
+++ /dev/null
@@ -1,44 +0,0 @@
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerStyleModule('app-theme', 'myplugin-app-theme');
-      plugin.registerStyleModule('app-theme-light', 'myplugin-app-theme-light');
-      plugin.registerStyleModule('app-theme-dark', 'myplugin-app-theme-dark');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="myplugin-app-theme">
-  <template>
-    <style>
-      html {
-        --primary-text-color: #F00BAA;
-      }
-    </style>
-  </template>
-</dom-module>
-
-<dom-module id="myplugin-app-theme-light">
-  <template>
-    <style>
-      html {
-        --header-background-color: #F01BAA;
-        --header-title-content: "MyGerrit";
-        --footer-background-color: #F02BAA;
-      }
-    </style>
-  </template>
-</dom-module>
-
-<dom-module id="myplugin-app-theme-dark">
-  <template>
-    <style>
-      html {
-        --primary-text-color: red;
-        --header-background-color: black;
-        --header-title-content: "MyGerrit Dark";
-        --footer-background-color: yellow;
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/embed/README.md b/polygerrit-ui/app/embed/README.md
index bef098b..4e1677de 100644
--- a/polygerrit-ui/app/embed/README.md
+++ b/polygerrit-ui/app/embed/README.md
@@ -1,4 +1,4 @@
-This folder contains shared components that can be used independently from Gerrit.
+This folder contains shared components that can be used independently of Gerrit.
 
 ### gr-diff
 
@@ -10,4 +10,4 @@
 
 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`.
+To customize the style of the diff, you can use `css variables`, all supported variables defined in `polygerrit-ui/app/styles/themes/app-theme.js` and `polygerrit-ui/app/styles/themes/dark-theme.js`.
diff --git a/polygerrit-ui/app/embed/app-context-init.js b/polygerrit-ui/app/embed/app-context-init.js
deleted file mode 100644
index 55a5866..0000000
--- a/polygerrit-ui/app/embed/app-context-init.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {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/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
new file mode 100644
index 0000000..6a30477
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -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 {appContext} from '../services/app-context';
+import {FlagsService} from '../services/flags/flags';
+import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
+import {AuthService} from '../services/gr-auth/gr-auth';
+
+class MockFlagsService implements FlagsService {
+  isEnabled() {
+    return false;
+  }
+
+  /**
+   * @returns array of all enabled experiments.
+   */
+  get enabledExperiments() {
+    return [];
+  }
+}
+
+class MockAuthService implements AuthService {
+  clearCache() {}
+
+  get isAuthed() {
+    return false;
+  }
+
+  authCheck() {
+    return Promise.resolve(false);
+  }
+
+  baseUrl = '';
+
+  setup() {}
+
+  fetch() {
+    const blob = new Blob();
+    const init = {status: 200, statusText: 'Ack'};
+    const response = new Response(blob, init);
+    return Promise.resolve(response);
+  }
+}
+
+// Setup mocks for appContext.
+// This is a temporary solution
+// TODO(dmfilippov): find a better solution for gr-diff
+export function initDiffAppContext() {
+  function setMock(serviceName: string, setupMock: unknown) {
+    Object.defineProperty(appContext, serviceName, {
+      get() {
+        return setupMock;
+      },
+    });
+  }
+  setMock('flagsService', new MockFlagsService());
+  setMock('reportingService', grReportingMock);
+  setMock('authService', new MockAuthService());
+}
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
new file mode 100644
index 0000000..832c931
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.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 '../test/common-test-setup-karma.js';
+import {appContext} from '../services/app-context.js';
+import {initDiffAppContext} from './gr-diff-app-context-init.js';
+suite('gr diff app context initializer tests', () => {
+  setup(() => {
+    initDiffAppContext();
+  });
+
+  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);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/embed/gr-diff.js b/polygerrit-ui/app/embed/gr-diff.js
deleted file mode 100644
index a8b7e03..0000000
--- a/polygerrit-ui/app/embed/gr-diff.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-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/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
new file mode 100644
index 0000000..405d22e
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -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.
+ */
+
+// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
+// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
+// Because gr-diff.js is a shared component, it shouldn' pollute global
+// variables. If an application wants to use Polymer global variable -
+// the app must assign/import it and do not rely on the Polymer variable
+// exposed by shared gr-diff component.
+import '../scripts/bundled-polymer';
+import '../elements/diff/gr-diff/gr-diff';
+import '../elements/diff/gr-diff-cursor/gr-diff-cursor';
+import {initDiffAppContext} from './gr-diff-app-context-init';
+import {
+  GrDiffLine,
+  GrDiffLineType,
+} from '../elements/diff/gr-diff/gr-diff-line';
+import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
+
+// Setup appContext for diff.
+// TODO (dmfilippov): find a better solution
+initDiffAppContext();
+// Setup global variables for existing usages of this component
+window.GrDiffLine = GrDiffLine;
+window.GrDiffLineType = GrDiffLineType;
+window.GrAnnotation = GrAnnotation;
diff --git a/polygerrit-ui/app/externs/BUILD b/polygerrit-ui/app/externs/BUILD
deleted file mode 100644
index 26ead9a..0000000
--- a/polygerrit-ui/app/externs/BUILD
+++ /dev/null
@@ -1,25 +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.
-
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library")
-
-package(
-    default_visibility = ["//visibility:public"],
-)
-
-closure_js_library(
-    name = "plugin",
-    srcs = ["plugin.js"],
-    no_closure_library = True,
-)
diff --git a/polygerrit-ui/app/externs/plugin.js b/polygerrit-ui/app/externs/plugin.js
deleted file mode 100644
index c88c724..0000000
--- a/polygerrit-ui/app/externs/plugin.js
+++ /dev/null
@@ -1,30 +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.
- */
-
-/**
- * @fileoverview Closure compiler externs for the Gerrit UI plugins.
- * @externs
- */
-
-/* eslint-disable no-var */
-
-var Gerrit = {};
-
-/**
- * @param {!Function} callback
- */
-Gerrit.install = function(callback) {};
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.js b/polygerrit-ui/app/gr-diff/gr-diff-root.js
deleted file mode 100644
index bb5d602..0000000
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-window.Gerrit = window.Gerrit || {};
-import '../elements/diff/gr-diff/gr-diff.js';
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.ts b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
new file mode 100644
index 0000000..fbe81fb
--- /dev/null
+++ b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
@@ -0,0 +1,18 @@
+/**
+ * @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 '../elements/diff/gr-diff/gr-diff';
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
new file mode 100644
index 0000000..abbcbc8
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
@@ -0,0 +1,136 @@
+/**
+ * @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 {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+import {property} from '@polymer/decorators';
+import {ServerInfo} from '../../types/common';
+
+/**
+ * @polymer
+ * @mixinFunction
+ */
+export const ChangeTableMixin = dedupingMixin(
+  <T extends Constructor<PolymerElement>>(
+    superClass: T
+  ): T & Constructor<ChangeTableMixinInterface> => {
+    /**
+     * @polymer
+     * @mixinClass
+     */
+    class Mixin extends superClass {
+      @property({type: Array})
+      readonly columnNames: string[] = [
+        'Subject',
+        'Status',
+        'Owner',
+        'Assignee',
+        'Reviewers',
+        'Comments',
+        'Repo',
+        'Branch',
+        'Updated',
+        'Size',
+      ];
+
+      /**
+       * Returns the complement to the given column array
+       *
+       */
+      getComplementColumns(columns: string[]) {
+        return this.columnNames.filter(column => !columns.includes(column));
+      }
+
+      isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
+        if (!columnsToDisplay || !columnToCheck) {
+          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.
+       *
+       */
+      isColumnEnabled(
+        column: string,
+        config: ServerInfo,
+        experiments: string[]
+      ) {
+        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;
+      }
+
+      /**
+       * @return enabled columns, see isColumnEnabled().
+       */
+      getEnabledColumns(
+        columns: string[],
+        config: ServerInfo,
+        experiments: string[]
+      ) {
+        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.
+       *
+       * @return If the column was renamed, returns a new array
+       * with the corrected name. Otherwise, it returns the original param.
+       */
+      getVisibleColumns(columns: string[]) {
+        const projectIndex = columns.indexOf('Project');
+        if (projectIndex === -1) {
+          return columns;
+        }
+        const newColumns = [...columns];
+        newColumns[projectIndex] = 'Repo';
+        return newColumns;
+      }
+    }
+
+    return Mixin;
+  }
+);
+
+export interface ChangeTableMixinInterface {
+  readonly columnNames: string[];
+  getComplementColumns(columns: string[]): string[];
+  isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]): boolean;
+  isColumnEnabled(
+    column: string,
+    config: ServerInfo,
+    experiments: string[]
+  ): boolean;
+  getEnabledColumns(
+    columns: string[],
+    config: ServerInfo,
+    experiments: string[]
+  ): string[];
+  getVisibleColumns(columns: string[]): string[];
+}
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
new file mode 100644
index 0000000..daf10ce
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {ChangeTableMixin} from './gr-change-table-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+class GrChangeTableMixinTestElement extends
+  ChangeTableMixin(PolymerElement) {
+  static get is() { return 'gr-change-table-mixin-test-element'; }
+}
+
+customElements.define(GrChangeTableMixinTestElement.is,
+    GrChangeTableMixinTestElement);
+
+const basicFixture = fixtureFromElement(
+    'gr-change-table-mixin-test-element');
+
+suite('gr-change-table-mixin tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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']));
+  });
+});
+
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
new file mode 100644
index 0000000..af89194
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
@@ -0,0 +1,78 @@
+/**
+ * @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 {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+
+/**
+ * @polymer
+ * @mixinFunction
+ */
+export const ListViewMixin = dedupingMixin(
+  <T extends Constructor<PolymerElement>>(
+    superClass: T
+  ): T & Constructor<ListViewMixinInterface> => {
+    /**
+     * @polymer
+     * @mixinClass
+     */
+    class Mixin extends superClass {
+      computeLoadingClass(loading: boolean): string {
+        return loading ? 'loading' : '';
+      }
+
+      computeShownItems<T>(items: T[]): T[] {
+        return items.slice(0, 25);
+      }
+
+      getUrl(path: string, item: string) {
+        return getBaseUrl() + path + encodeURL(item, true);
+      }
+
+      getFilterValue<T extends ListViewParams>(params: T): string {
+        if (!params) {
+          return '';
+        }
+        return params.filter || '';
+      }
+
+      getOffsetValue<T extends ListViewParams>(params: T): number {
+        if (params?.offset) {
+          return Number(params.offset);
+        }
+        return 0;
+      }
+    }
+
+    return Mixin;
+  }
+);
+
+export interface ListViewMixinInterface {
+  computeLoadingClass(loading: boolean): string;
+  computeShownItems<T>(items: T[]): T[];
+  getUrl(path: string, item: string): string;
+  getFilterValue<T extends ListViewParams>(params: T): string;
+  getOffsetValue<T extends ListViewParams>(params: T): number;
+}
+
+export interface ListViewParams {
+  filter?: string | null;
+  offset?: number | string;
+}
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js
new file mode 100644
index 0000000..407f29f
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js
@@ -0,0 +1,79 @@
+/**
+ * @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 '../../test/common-test-setup-karma.js';
+import {ListViewMixin} from './gr-list-view-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+const basicFixture = fixtureFromElement(
+    'gr-list-view-mixin-test-element');
+
+class GrListViewMixinTestElement extends
+  ListViewMixin(PolymerElement) {
+  static get is() { return 'gr-list-view-mixin-test-element'; }
+}
+
+customElements.define(GrListViewMixinTestElement.is,
+    GrListViewMixinTestElement);
+
+suite('gr-list-view-mixin tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  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);
+  });
+});
+
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
new file mode 100644
index 0000000..08b18a3
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -0,0 +1,221 @@
+/**
+ * @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 '../../elements/shared/gr-tooltip/gr-tooltip';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {getRootElement} from '../../scripts/rootElement';
+import {property, observe} from '@polymer/decorators';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {GrTooltip} from '../../elements/shared/gr-tooltip/gr-tooltip';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+
+const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
+
+/** The interface corresponding to TooltipMixin */
+export interface TooltipMixinInterface {
+  hasTooltip: boolean;
+  positionBelow: boolean;
+  _isTouchDevice: boolean;
+  _tooltip: GrTooltip | null;
+  _titleText: string;
+  _hasSetupTooltipListeners: boolean;
+}
+
+/**
+ * @polymer
+ * @mixinFunction
+ */
+export const TooltipMixin = dedupingMixin(
+  <T extends Constructor<PolymerElement>>(
+    superClass: T
+  ): T & Constructor<TooltipMixinInterface> => {
+    /**
+     * @polymer
+     * @mixinClass
+     */
+    class Mixin extends superClass {
+      @property({type: Boolean})
+      hasTooltip = false;
+
+      @property({type: Boolean, reflectToAttribute: true})
+      positionBelow = false;
+
+      @property({type: Boolean})
+      _isTouchDevice = 'ontouchstart' in document.documentElement;
+
+      @property({type: Object})
+      _tooltip: GrTooltip | null = null;
+
+      @property({type: String})
+      _titleText = '';
+
+      @property({type: Boolean})
+      _hasSetupTooltipListeners = false;
+
+      // Handler for mouseenter event
+      private mouseenterHandler?: (e: MouseEvent) => void;
+
+      // Hanlder for scrolling on window
+      private readonly windowScrollHandler: () => void;
+
+      // Hanlder for showing the tooltip, will be attached to certain events
+      private readonly showHandler: () => void;
+
+      // Hanlder for hiding the tooltip, will be attached to certain events
+      private readonly hideHandler: () => void;
+
+      // tslint:disable-next-line:no-any Required for constructor signature.
+      constructor(..._: any[]) {
+        super();
+        this.windowScrollHandler = () => this._handleWindowScroll();
+        this.showHandler = () => this._handleShowTooltip();
+        this.hideHandler = () => this._handleHideTooltip();
+      }
+
+      /** @override */
+      disconnectedCallback() {
+        super.disconnectedCallback();
+        // NOTE: if you define your own `detached` in your component
+        // then this won't take affect (as its not a class yet)
+        this._handleHideTooltip();
+        if (this.mouseenterHandler) {
+          this.removeEventListener('mouseenter', this.mouseenterHandler);
+        }
+        window.removeEventListener('scroll', this.windowScrollHandler);
+      }
+
+      @observe('hasTooltip')
+      _setupTooltipListeners() {
+        if (!this.mouseenterHandler) {
+          this.mouseenterHandler = this.showHandler;
+        }
+
+        if (!this.hasTooltip) {
+          // if attribute set to false, remove the listener
+          this.removeEventListener('mouseenter', this.mouseenterHandler);
+          this._hasSetupTooltipListeners = false;
+          return;
+        }
+
+        if (this._hasSetupTooltipListeners) {
+          return;
+        }
+        this._hasSetupTooltipListeners = true;
+
+        this.addEventListener('mouseenter', this.mouseenterHandler);
+      }
+
+      _handleShowTooltip() {
+        if (this._isTouchDevice) {
+          return;
+        }
+
+        if (
+          !this.hasAttribute('title') ||
+          this.getAttribute('title') === '' ||
+          this._tooltip
+        ) {
+          return;
+        }
+
+        // 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', '');
+
+        const tooltip = document.createElement('gr-tooltip');
+        tooltip.text = this._titleText;
+        tooltip.maxWidth = this.getAttribute('max-width') || '';
+        tooltip.positionBelow = this.hasAttribute('position-below');
+
+        // 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 = 'initial';
+
+        this._tooltip = tooltip;
+        window.addEventListener('scroll', this.windowScrollHandler);
+        this.addEventListener('mouseleave', this.hideHandler);
+        this.addEventListener('click', this.hideHandler);
+      }
+
+      _handleHideTooltip() {
+        if (this._isTouchDevice) {
+          return;
+        }
+        if (!this.hasAttribute('title') || !this._titleText) {
+          return;
+        }
+
+        window.removeEventListener('scroll', this.windowScrollHandler);
+        this.removeEventListener('mouseleave', this.hideHandler);
+        this.removeEventListener('click', this.hideHandler);
+        this.setAttribute('title', this._titleText);
+
+        if (this._tooltip?.parentNode) {
+          this._tooltip.parentNode.removeChild(this._tooltip);
+        }
+        this._tooltip = null;
+      }
+
+      _handleWindowScroll() {
+        if (!this._tooltip) {
+          return;
+        }
+
+        this._positionTooltip(this._tooltip);
+      }
+
+      _positionTooltip(tooltip: GrTooltip) {
+        // This flush is needed for tooltips to be positioned correctly in Firefox
+        // and Safari.
+        flush();
+        const rect = this.getBoundingClientRect();
+        const boxRect = tooltip.getBoundingClientRect();
+        if (!tooltip.parentElement) {
+          return;
+        }
+        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`;
+        }
+      }
+    }
+
+    return Mixin;
+  }
+);
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
new file mode 100644
index 0000000..209c83a
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
@@ -0,0 +1,137 @@
+/**
+ * @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 '../../test/common-test-setup-karma.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {TooltipMixin} from './gr-tooltip-mixin.js';
+
+const basicFixture = fixtureFromElement('gr-tooltip-mixin-element');
+
+class GrTooltipMixinTestElement extends TooltipMixin(PolymerElement) {
+  static get is() {
+    return 'gr-tooltip-mixin-element';
+  }
+}
+
+customElements.define(GrTooltipMixinTestElement.is,
+    GrTooltipMixinTestElement);
+
+suite('gr-tooltip-mixin tests', () => {
+  let element;
+
+  function makeTooltip(tooltipRect, parentRect) {
+    return {
+      getBoundingClientRect() { return tooltipRect; },
+      updateStyles: sinon.stub(),
+      style: {left: 0, top: 0},
+      parentElement: {
+        getBoundingClientRect() { return parentRect; },
+      },
+    };
+  }
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('normal position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      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', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      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', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      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', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      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', () => {
+    sinon.stub(element, '_handleHideTooltip');
+    element.remove();
+    flush();
+    assert.isTrue(element._handleHideTooltip.called);
+  });
+
+  test('sets up listeners when has-tooltip is changed', () => {
+    const addListenerStub = sinon.stub(element, 'addEventListener');
+    element.hasTooltip = true;
+    assert.isTrue(addListenerStub.called);
+  });
+
+  test('clean up listeners when has-tooltip changed to false', () => {
+    const removeListenerStub = sinon.stub(element, 'removeEventListener');
+    element.hasTooltip = true;
+    element.hasTooltip = false;
+    assert.isTrue(removeListenerStub.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
new file mode 100644
index 0000000..48a4848
--- /dev/null
+++ b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
@@ -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 {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {Constructor} from '../../utils/common-util';
+
+// The mixinBehaviors clears all type information about superClass.
+// As a workaround, we define IronFitMixin with correct type.
+// Due to the following issues:
+// https://github.com/microsoft/TypeScript/issues/15870
+// https://github.com/microsoft/TypeScript/issues/9944
+// we have to import IronFitBehavior in the same file where IronFitMixin
+// is used. To ensure that this import can't be avoided, the second parameter
+// is added. Usage example:
+// class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
+// The code 'IronFitBehavior as IronFitBehavior' required, becuase IronFitBehavior
+// defined as an object, not as IronFitBehavior instance.
+
+export const IronFitMixin = <T extends Constructor<PolymerElement>>(
+  superClass: T,
+  _: IronFitBehavior
+): T & Constructor<IronFitBehavior> =>
+  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
+  // which will fail the type check due to missing IronFitBehavior interface
+  mixinBehaviors([IronFitBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
new file mode 100644
index 0000000..4884ec2
--- /dev/null
+++ b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
@@ -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 {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {Constructor} from '../../utils/common-util';
+
+// The mixinBehaviors clears all type information about superClass.
+// As a workaround, we define IronOverlayMixin with correct type.
+// Due to the following issues:
+// https://github.com/microsoft/TypeScript/issues/15870
+// https://github.com/microsoft/TypeScript/issues/9944
+// we have to import IronOverlayBehavior in the same file where IronOverlayMixin
+// is used. To ensure that this import can't be avoided, the second parameter
+// is added. Usage example:
+// class Element extends IronOverlayMixin(PolymerElement, IronOverlayBehavior as IronOverlayBehavior)
+// The code 'IronOverlayBehavior as IronOverlayBehavior' required, because
+// IronOverlayBehavior defined as an object, not as IronOverlayBehavior instance.
+export const IronOverlayMixin = <T extends Constructor<PolymerElement>>(
+  superClass: T,
+  _: IronOverlayBehavior
+): T & Constructor<IronOverlayBehavior> =>
+  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
+  // instead which will fail the type check due to missing
+  // IronOverlayBehavior interface
+  mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
new file mode 100644
index 0000000..e383811
--- /dev/null
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -0,0 +1,1097 @@
+/**
+ * @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(
+      Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+
+  // Ordinary shortcut with multiple bindings.
+  this.bindShortcut(
+      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(
+      Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.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(
+      Shortcut.EXPAND_ALL_COMMENT_THREADS, SPECIAL_SHORTCUT.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 {
+      [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.
+*/
+
+import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {property} from '@polymer/decorators';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+import {
+  CustomKeyboardEvent,
+  ShortcutTriggeredEventDetail,
+} from '../../types/events';
+
+/** Enum for all special shortcuts */
+export enum SPECIAL_SHORTCUT {
+  DOC_ONLY = 'DOC_ONLY',
+  GO_KEY = 'GO_KEY',
+  V_KEY = 'V_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 V_KEY_TIMEOUT_MS = 1000;
+
+const THROTTLE_INTERVAL_MS = 500;
+
+/**
+ * Enum for all shortcut sections, where that shortcut should be applied to.
+ */
+export enum ShortcutSection {
+  ACTIONS = 'Actions',
+  DIFFS = 'Diffs',
+  EVERYWHERE = 'Everywhere',
+  FILE_LIST = 'File list',
+  NAVIGATION = 'Navigation',
+  REPLY_DIALOG = 'Reply dialog',
+}
+
+/**
+ * Enum for all possible shortcut names.
+ */
+export enum 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',
+  DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
+  DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
+  DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
+
+  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',
+  TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
+  OPEN_FILE_LIST = 'OPEN_FILE_LIST',
+
+  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',
+}
+
+export type SectionView = Array<{binding: string[][]; text: string}>;
+
+/**
+ * The interface for listener for shortcut events.
+ */
+export type ShortcutListener = (
+  viewMap?: Map<ShortcutSection, SectionView>
+) => void;
+
+interface ShortcutEnabledElement extends PolymerElement {
+  // TODO: should replace with Map so we can have proper type here
+  keyboardShortcuts(): {[shortcut: string]: string};
+}
+
+interface ShortcutHelpItem {
+  shortcut: Shortcut;
+  text: string;
+}
+
+// TODO(TS): rename to something more meaningful
+const _help = new Map<ShortcutSection, ShortcutHelpItem[]>();
+
+function _describe(shortcut: Shortcut, section: ShortcutSection, text: string) {
+  if (!_help.has(section)) {
+    _help.set(section, []);
+  }
+  const shortcuts = _help.get(section);
+  if (shortcuts) {
+    shortcuts.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.DIFF_AGAINST_BASE,
+  ShortcutSection.ACTIONS,
+  'Diff against base'
+);
+_describe(
+  Shortcut.DIFF_AGAINST_LATEST,
+  ShortcutSection.ACTIONS,
+  'Diff against latest patchset'
+);
+_describe(
+  Shortcut.DIFF_BASE_AGAINST_LEFT,
+  ShortcutSection.ACTIONS,
+  'Diff base against left'
+);
+_describe(
+  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+  ShortcutSection.ACTIONS,
+  'Diff right against latest'
+);
+_describe(
+  Shortcut.DIFF_BASE_AGAINST_LATEST,
+  ShortcutSection.ACTIONS,
+  'Diff base against latest'
+);
+
+_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
+_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
+_describe(
+  Shortcut.DIFF_AGAINST_BASE,
+  ShortcutSection.DIFFS,
+  'Diff against base'
+);
+_describe(
+  Shortcut.DIFF_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff against latest patchset'
+);
+_describe(
+  Shortcut.DIFF_BASE_AGAINST_LEFT,
+  ShortcutSection.DIFFS,
+  'Diff base against left'
+);
+_describe(
+  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff right against latest'
+);
+_describe(
+  Shortcut.DIFF_BASE_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff base against latest'
+);
+_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.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Hide/Display 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_HIDE_ALL_COMMENT_THREADS,
+  ShortcutSection.FILE_LIST,
+  'Hide/Display all comment threads'
+);
+_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.
+
+function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
+  const event = dom(e.detail ? e.detail.keyboardEvent : e);
+  // TODO(TS): worth checking if this still holds or not, if no, remove this.
+  // When e is a keyboardEvent, e.event is not null.
+  if ('event' in event && (event as CustomKeyboardEvent).event) {
+    return (event as CustomKeyboardEvent).event;
+  }
+  return event as CustomKeyboardEvent;
+}
+
+/**
+ * Shortcut manager, holds all hosts, bindings and listners.
+ */
+export class ShortcutManager {
+  private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
+
+  private readonly bindings = new Map<Shortcut, string[]>();
+
+  public _testOnly_getBindings() {
+    return this.bindings;
+  }
+
+  public _testOnly_isEmpty() {
+    return this.activeHosts.size === 0 && this.listeners.size === 0;
+  }
+
+  private readonly listeners = new Set<ShortcutListener>();
+
+  bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
+    this.bindings.set(shortcut, bindings);
+  }
+
+  getBindingsForShortcut(shortcut: Shortcut) {
+    return this.bindings.get(shortcut);
+  }
+
+  attachHost(host: PolymerElement | ShortcutEnabledElement) {
+    if (!('keyboardShortcuts' in host)) {
+      return;
+    }
+    const shortcuts = host.keyboardShortcuts();
+    this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
+    this.notifyListeners();
+    return shortcuts;
+  }
+
+  detachHost(host: PolymerElement) {
+    if (this.activeHosts.delete(host)) {
+      this.notifyListeners();
+      return true;
+    }
+    return false;
+  }
+
+  addListener(listener: ShortcutListener) {
+    this.listeners.add(listener);
+    listener(this.directoryView());
+  }
+
+  removeListener(listener: ShortcutListener) {
+    return this.listeners.delete(listener);
+  }
+
+  getDescription(section: ShortcutSection, shortcutName: Shortcut) {
+    const bindings = _help.get(section);
+    let desc = '';
+    if (bindings) {
+      const binding = bindings.find(
+        binding => binding.shortcut === shortcutName
+      );
+      desc = binding ? binding.text : '';
+    }
+    return desc;
+  }
+
+  getShortcut(shortcutName: Shortcut) {
+    const bindings = this.bindings.get(shortcutName);
+    return bindings
+      ? bindings
+          .map(binding => this.describeBinding(binding).join('+'))
+          .join(',')
+      : '';
+  }
+
+  activeShortcutsBySection() {
+    const activeShortcuts = new Set<string>();
+    this.activeHosts.forEach(shortcuts => {
+      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
+    });
+
+    const activeShortcutsBySection = new Map<
+      ShortcutSection,
+      ShortcutHelpItem[]
+    >();
+    _help.forEach((shortcutList, section) => {
+      shortcutList.forEach(shortcutHelp => {
+        if (activeShortcuts.has(shortcutHelp.shortcut)) {
+          if (!activeShortcutsBySection.has(section)) {
+            activeShortcutsBySection.set(section, []);
+          }
+          // From previous condition, the `get(section)`
+          // should always return a valid result
+          activeShortcutsBySection.get(section)!.push(shortcutHelp);
+        }
+      });
+    });
+    return activeShortcutsBySection;
+  }
+
+  directoryView() {
+    const view = new Map<ShortcutSection, SectionView>();
+    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+      const sectionView: Array<{binding: string[][]; text: string}> = [];
+      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: string[][]): string[][][] {
+    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)));
+      }
+    }
+    return [];
+  }
+
+  comboSetDisplayWidth(bindingDesc: string[][]) {
+    const bindingSizer = (binding: string[]) =>
+      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: Shortcut): string[][] | null {
+    const bindings = this.bindings.get(shortcut);
+    if (!bindings) {
+      return null;
+    }
+    // TODO(TS): should check base on length to differentiate two
+    // cases
+    if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
+      return bindings
+        .slice(1)
+        .map(binding => this._describeKey(binding))
+        .map(binding => ['g'].concat(binding));
+    }
+    if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
+      return bindings
+        .slice(1)
+        .map(binding => this._describeKey(binding))
+        .map(binding => ['v'].concat(binding));
+    }
+
+    return bindings
+      .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
+      .map(binding => this.describeBinding(binding));
+  }
+
+  _describeKey(key: string) {
+    switch (key) {
+      case 'shift':
+        return 'Shift';
+      case 'meta':
+        return 'Meta';
+      case 'ctrl':
+        return 'Ctrl';
+      case 'enter':
+        return 'Enter';
+      case 'up':
+        return '\u2191'; // ↑
+      case 'down':
+        return '\u2193'; // ↓
+      case 'left':
+        return '\u2190'; // ←
+      case 'right':
+        return '\u2192'; // →
+      default:
+        return key;
+    }
+  }
+
+  describeBinding(binding: string) {
+    // single key bindings
+    if (binding.length === 1) {
+      return [binding];
+    }
+    return binding
+      .split(':')[0]
+      .split('+')
+      .map(part => this._describeKey(part));
+  }
+
+  notifyListeners() {
+    const view = this.directoryView();
+    this.listeners.forEach(listener => listener(view));
+  }
+}
+
+const shortcutManager = new ShortcutManager();
+
+/**
+ * Enum for supported modifiers.
+ */
+export enum Modifier {
+  SHIFT_KEY = 'shiftKey',
+  CTRL_KEY = 'ctrlKey',
+  META_KEY = 'metaKey',
+  // Add when you need it
+}
+
+interface IronA11yKeysMixinConstructor {
+  // Note: this is needed to have same interface as other mixins
+  new (...args: any[]): IronA11yKeysBehavior;
+}
+/**
+ * @polymer
+ * @mixinFunction
+ */
+const InternalKeyboardShortcutMixin = dedupingMixin(
+  <T extends Constructor<PolymerElement> & IronA11yKeysMixinConstructor>(
+    superClass: T
+  ): T & Constructor<KeyboardShortcutMixinInterface> => {
+    /**
+     * @polymer
+     * @mixinClass
+     */
+    class Mixin extends superClass {
+      @property({type: Number})
+      _shortcut_go_key_last_pressed: number | null = null;
+
+      @property({type: Number})
+      _shortcut_v_key_last_pressed: number | null = null;
+
+      @property({type: Object})
+      _shortcut_go_table: Map<string, string> = new Map();
+
+      @property({type: Object})
+      _shortcut_v_table: Map<string, string> = new Map();
+
+      Shortcut = Shortcut;
+
+      ShortcutSection = ShortcutSection;
+
+      modifierPressed(event: CustomKeyboardEvent) {
+        /* We are checking for g/v as modifiers pressed. There are cases such as
+         * pressing v and then /, where we want the handler for / to be triggered.
+         * TODO(dhruvsri): find a way to support that keyboard combination
+         */
+        const e = getKeyboardEvent(event);
+        return (
+          e.altKey ||
+          e.ctrlKey ||
+          e.metaKey ||
+          e.shiftKey ||
+          !!this._inGoKeyMode() ||
+          !!this.inVKeyMode()
+        );
+      }
+
+      isModifierPressed(e: CustomKeyboardEvent, modifier: Modifier) {
+        return getKeyboardEvent(e)[modifier];
+      }
+
+      shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent) {
+        const e = getKeyboardEvent(event);
+        // TODO(TS): maybe override the EventApi, narrow it down to Element always
+        const target = (dom(e) as EventApi).rootTarget as Element;
+        const tagName = target.tagName;
+        const type = target.getAttribute('type');
+        if (
+          // Suppress shortcuts on <input> and <textarea>, but not on
+          // checkboxes, because we want to enable workflows like 'click
+          // mark-reviewed and then press ] to go to the next file'.
+          (tagName === 'INPUT' && type !== 'checkbox') ||
+          tagName === 'TEXTAREA' ||
+          // Suppress shortcuts if the key is 'enter' and target is an anchor.
+          (e.keyCode === 13 && tagName === 'A')
+        ) {
+          return true;
+        }
+        const path = e.composedPath();
+        for (let i = 0; path && i < path.length; i++) {
+          // TODO(TS): narrow this down to Element from EventTarget first
+          if ((path[i] as Element).tagName === 'GR-OVERLAY') {
+            return true;
+          }
+        }
+        const detail: ShortcutTriggeredEventDetail = {
+          event: e,
+          goKey: this._inGoKeyMode(),
+          vKey: this.inVKeyMode(),
+        };
+        this.dispatchEvent(
+          new CustomEvent('shortcut-triggered', {
+            detail,
+            composed: true,
+            bubbles: true,
+          })
+        );
+        return false;
+      }
+
+      // Alias for getKeyboardEvent.
+      getKeyboardEvent(e: CustomKeyboardEvent) {
+        return getKeyboardEvent(e);
+      }
+
+      // TODO(TS): maybe remove, no reference in the code base
+      getRootTarget(e: CustomKeyboardEvent) {
+        // TODO(TS): worth checking if we can limit this to EventApi only
+        // dom currently returns DomNativeApi|EventApi
+        return (dom(getKeyboardEvent(e)) as EventApi).rootTarget;
+      }
+
+      bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
+        shortcutManager.bindShortcut(shortcut, ...bindings);
+      }
+
+      createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+        const desc = shortcutManager.getDescription(section, shortcutName);
+        const shortcut = shortcutManager.getShortcut(shortcutName);
+        return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
+      }
+
+      _throttleWrap(fn: (e: Event) => void) {
+        let lastCall: number | undefined;
+        return (e: Event) => {
+          if (
+            lastCall !== undefined &&
+            Date.now() - lastCall < THROTTLE_INTERVAL_MS
+          ) {
+            return;
+          }
+          lastCall = Date.now();
+          fn(e);
+        };
+      }
+
+      _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
+        const bindings = shortcutManager.getBindingsForShortcut(shortcut);
+        if (!bindings) {
+          return;
+        }
+        if (bindings[0] === SPECIAL_SHORTCUT.DOC_ONLY) {
+          return;
+        }
+        if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
+          bindings
+            .slice(1)
+            .forEach(binding => this._shortcut_go_table.set(binding, handler));
+        } else if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
+          // for each binding added with the go/v key, we set the handler to be
+          // handleVKeyAction. handleVKeyAction then looks up in th
+          // shortcut_table to see what the relevant handler should be
+          bindings
+            .slice(1)
+            .forEach(binding => this._shortcut_v_table.set(binding, handler));
+        } else {
+          this.addOwnKeyBinding(bindings.join(' '), handler);
+        }
+      }
+
+      /** @override */
+      connectedCallback() {
+        super.connectedCallback();
+        const shortcuts = shortcutManager.attachHost(this);
+        if (!shortcuts) {
+          return;
+        }
+
+        for (const key of Object.keys(shortcuts)) {
+          // TODO(TS): not needed if convert shortcuts to Map
+          this._addOwnKeyBindings(key as Shortcut, shortcuts[key]);
+        }
+
+        // each component that uses this behaviour must be aware if go key is
+        // pressed or not, since it needs to check it as a modifier
+        this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
+        this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
+
+        // If any of the shortcuts utilized GO_KEY, then they are handled
+        // directly by this behavior.
+        if (this._shortcut_go_table.size > 0) {
+          this._shortcut_go_table.forEach((_, key) => {
+            this.addOwnKeyBinding(key, '_handleGoAction');
+          });
+        }
+
+        this.addOwnKeyBinding('v:keydown', '_handleVKeyDown');
+        this.addOwnKeyBinding('v:keyup', '_handleVKeyUp');
+        if (this._shortcut_v_table.size > 0) {
+          this._shortcut_v_table.forEach((_, key) => {
+            this.addOwnKeyBinding(key, '_handleVAction');
+          });
+        }
+      }
+
+      /** @override */
+      disconnectedCallback() {
+        super.disconnectedCallback();
+        if (shortcutManager.detachHost(this)) {
+          this.removeOwnKeyBindings();
+        }
+      }
+
+      keyboardShortcuts() {
+        return {};
+      }
+
+      addKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
+        shortcutManager.addListener(listener);
+      }
+
+      removeKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
+        shortcutManager.removeListener(listener);
+      }
+
+      _handleVKeyDown(e: CustomKeyboardEvent) {
+        if (this.shouldSuppressKeyboardShortcut(e)) return;
+        this._shortcut_v_key_last_pressed = Date.now();
+      }
+
+      _handleVKeyUp() {
+        setTimeout(() => {
+          this._shortcut_v_key_last_pressed = null;
+        }, V_KEY_TIMEOUT_MS);
+      }
+
+      private inVKeyMode() {
+        return !!(
+          this._shortcut_v_key_last_pressed &&
+          Date.now() - this._shortcut_v_key_last_pressed <= V_KEY_TIMEOUT_MS
+        );
+      }
+
+      _handleVAction(e: CustomKeyboardEvent) {
+        if (
+          !this.inVKeyMode() ||
+          !this._shortcut_v_table.has(e.detail.key) ||
+          this.shouldSuppressKeyboardShortcut(e)
+        ) {
+          return;
+        }
+        e.preventDefault();
+        const handler = this._shortcut_v_table.get(e.detail.key);
+        if (handler) {
+          // TODO(TS): should fix this
+          (this as any)[handler](e);
+        }
+      }
+
+      _handleGoKeyDown(e: CustomKeyboardEvent) {
+        if (this.shouldSuppressKeyboardShortcut(e)) return;
+        this._shortcut_go_key_last_pressed = Date.now();
+      }
+
+      _handleGoKeyUp() {
+        // Set go_key_last_pressed to null `GO_KEY_TIMEOUT_MS` after keyup event
+        // so that users can trigger `g + i` by pressing g and i quickly.
+        setTimeout(() => {
+          this._shortcut_go_key_last_pressed = null;
+        }, GO_KEY_TIMEOUT_MS);
+      }
+
+      _inGoKeyMode() {
+        return !!(
+          this._shortcut_go_key_last_pressed &&
+          Date.now() - this._shortcut_go_key_last_pressed <= GO_KEY_TIMEOUT_MS
+        );
+      }
+
+      _handleGoAction(e: CustomKeyboardEvent) {
+        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);
+        if (handler) {
+          // TODO(TS): should fix this
+          (this as any)[handler](e);
+        }
+      }
+    }
+
+    return Mixin;
+  }
+);
+
+// The following doesn't work (IronA11yKeysBehavior crashes):
+// const KeyboardShortcutMixin = dedupingMixin(superClass => {
+//    class Mixin extends mixinBehaviors([IronA11yKeysBehavior], superClass) {
+//    ...
+//    }
+//    return Mixin;
+// }
+// This is a workaround
+export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
+  superClass: T
+): T & Constructor<KeyboardShortcutMixinInterface> => {
+  return InternalKeyboardShortcutMixin(
+    // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
+    // which will fail the type check due to missing IronA11yKeysBehavior interface
+    mixinBehaviors([IronA11yKeysBehavior], superClass) as any
+  );
+};
+
+/** The interface corresponding to KeyboardShortcutMixin */
+export interface KeyboardShortcutMixinInterface {
+  Shortcut: typeof Shortcut;
+  ShortcutSection: typeof ShortcutSection;
+  _shortcut_go_key_last_pressed: number | null;
+  _shortcut_v_key_last_pressed: number | null;
+  _shortcut_go_table: Map<string, string>;
+  _shortcut_v_table: Map<string, string>;
+  keyboardShortcuts(): {[key: string]: string | null};
+  createTitle(name: Shortcut, section: ShortcutSection): string;
+  bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
+  shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
+  modifierPressed(event: CustomKeyboardEvent): boolean;
+  isModifierPressed(event: CustomKeyboardEvent, modifier: Modifier): boolean;
+  getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent;
+  addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
+  removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
+  // TODO(TS): Remove underscore. Apparently not a private method.
+  _throttleWrap(eventListener: EventListener): EventListener;
+}
+
+export function _testOnly_getShortcutManagerInstance() {
+  return shortcutManager;
+}
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
new file mode 100644
index 0000000..180dbe7
--- /dev/null
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
@@ -0,0 +1,452 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {
+  KeyboardShortcutMixin, Shortcut,
+  ShortcutManager, ShortcutSection, SPECIAL_SHORTCUT,
+} from './keyboard-shortcut-mixin.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+const basicFixture =
+    fixtureFromElement('keyboard-shortcut-mixin-test-element');
+
+const withinOverlayFixture = fixtureFromTemplate(html`
+<gr-overlay>
+  <keyboard-shortcut-mixin-test-element>
+  </keyboard-shortcut-mixin-test-element>
+</gr-overlay>
+`);
+
+class GrKeyboardShortcutMixinTestElement extends
+  KeyboardShortcutMixin(PolymerElement) {
+  static get is() {
+    return 'keyboard-shortcut-mixin-test-element';
+  }
+
+  get keyBindings() {
+    return {
+      k: '_handleKey',
+      enter: '_handleKey',
+    };
+  }
+
+  _handleKey() {}
+}
+
+customElements.define(GrKeyboardShortcutMixinTestElement.is,
+    GrKeyboardShortcutMixinTestElement);
+
+suite('keyboard-shortcut-mixin tests', () => {
+  let element;
+  let overlay;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    overlay = withinOverlayFixture.instantiate();
+  });
+
+  suite('ShortcutManager', () => {
+    test('bindings management', () => {
+      const mgr = new ShortcutManager();
+      const NEXT_FILE = Shortcut.NEXT_FILE;
+
+      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+      assert.deepEqual(
+          mgr.getBindingsForShortcut(NEXT_FILE),
+          [']', '}', 'right']);
+    });
+
+    test('getShortcut', () => {
+      const mgr = new ShortcutManager();
+      const NEXT_FILE = Shortcut.NEXT_FILE;
+
+      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+      assert.equal(mgr.getShortcut(NEXT_FILE), '],},→');
+    });
+
+    test('getShortcut with modifiers', () => {
+      const mgr = new ShortcutManager();
+      const NEXT_FILE = Shortcut.NEXT_FILE;
+
+      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+      mgr.bindShortcut(NEXT_FILE, 'Shift+a:key');
+      assert.equal(mgr.getShortcut(NEXT_FILE), 'Shift+a');
+    });
+
+    suite('binding descriptions', () => {
+      function mapToObject(m) {
+        const o = {};
+        m.forEach((v, k) => o[k] = v);
+        return o;
+      }
+
+      test('single combo description', () => {
+        const mgr = new 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', '↑']);
+      });
+
+      test('combo set description', () => {
+        const mgr = new ShortcutManager();
+        assert.isNull(mgr.describeBindings(Shortcut.NEXT_FILE));
+
+        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
+            SPECIAL_SHORTCUT.GO_KEY, 'o');
+        assert.deepEqual(
+            mgr.describeBindings(Shortcut.GO_TO_OPENED_CHANGES),
+            [['g', 'o']]);
+
+        mgr.bindShortcut(Shortcut.NEXT_FILE, SPECIAL_SHORTCUT.DOC_ONLY,
+            ']', 'ctrl+shift+right:keyup');
+        assert.deepEqual(
+            mgr.describeBindings(Shortcut.NEXT_FILE),
+            [[']'], ['Ctrl', 'Shift', '→']]);
+
+        mgr.bindShortcut(Shortcut.PREV_FILE, '[');
+        assert.deepEqual(mgr.describeBindings(Shortcut.PREV_FILE), [['[']]);
+      });
+
+      test('combo set description width', () => {
+        const mgr = new 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 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 mgr = new ShortcutManager();
+        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
+        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
+        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES, 'g+o');
+        mgr.bindShortcut(Shortcut.SEARCH, '/');
+
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {});
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [Shortcut.NEXT_FILE]: null,
+            };
+          },
+        });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [ShortcutSection.NAVIGATION]: [
+                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [Shortcut.NEXT_LINE]: null,
+            };
+          },
+        });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [ShortcutSection.DIFFS]: [
+                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
+              ],
+              [ShortcutSection.NAVIGATION]: [
+                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [Shortcut.SEARCH]: null,
+              [Shortcut.GO_TO_OPENED_CHANGES]: null,
+            };
+          },
+        });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [ShortcutSection.DIFFS]: [
+                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
+              ],
+              [ShortcutSection.EVERYWHERE]: [
+                {shortcut: Shortcut.SEARCH, text: 'Search'},
+                {
+                  shortcut: Shortcut.GO_TO_OPENED_CHANGES,
+                  text: 'Go to Opened Changes',
+                },
+              ],
+              [ShortcutSection.NAVIGATION]: [
+                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
+      });
+
+      test('directory view', () => {
+        const mgr = new ShortcutManager();
+        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
+        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
+        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
+            SPECIAL_SHORTCUT.GO_KEY, 'o');
+        mgr.bindShortcut(Shortcut.SEARCH, '/');
+        mgr.bindShortcut(
+            Shortcut.SAVE_COMMENT, 'ctrl+enter', 'meta+enter',
+            'ctrl+s', 'meta+s');
+
+        assert.deepEqual(mapToObject(mgr.directoryView()), {});
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [Shortcut.GO_TO_OPENED_CHANGES]: null,
+              [Shortcut.NEXT_FILE]: null,
+              [Shortcut.NEXT_LINE]: null,
+              [Shortcut.SAVE_COMMENT]: null,
+              [Shortcut.SEARCH]: null,
+            };
+          },
+        });
+        assert.deepEqual(
+            mapToObject(mgr.directoryView()),
+            {
+              [ShortcutSection.DIFFS]: [
+                {binding: [['j']], text: 'Go to next line'},
+                {
+                  binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
+                  text: 'Save comment',
+                },
+                {
+                  binding: [['Ctrl', 's'], ['Meta', 's']],
+                  text: 'Save comment',
+                },
+              ],
+              [ShortcutSection.EVERYWHERE]: [
+                {binding: [['/']], text: 'Search'},
+                {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+              ],
+              [ShortcutSection.NAVIGATION]: [
+                {binding: [[']']], text: 'Go to next file'},
+              ],
+            });
+      });
+    });
+  });
+
+  test('doesn’t block kb shortcuts for non-allowed 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('doesn’t block kb shortcuts for checkboxes', done => {
+    const inputEl = document.createElement('input');
+    inputEl.setAttribute('type', 'checkbox');
+    element.appendChild(inputEl);
+    element._handleKey = e => {
+      assert.isFalse(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('keyboard-shortcut-mixin-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('keyboard-shortcut-mixin-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 = sinon.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 = sinon.spy(element, 'isModifierPressed');
+    element._handleKey = e => {
+      element.isModifierPressed(e, 'shiftKey');
+    };
+    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+  });
+
+  suite('GO_KEY timing', () => {
+    let handlerStub;
+
+    setup(() => {
+      element._shortcut_go_table.set('a', '_handleA');
+      handlerStub = element._handleA = sinon.stub();
+      sinon.stub(Date, 'now').returns(10000);
+    });
+
+    test('success', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sinon.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: () => {}};
+      sinon.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: () => {}};
+      sinon.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: () => {}};
+      sinon.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: () => {}};
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index 92a3db8..77400c6 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -1,4 +1,4 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
+load("@npm//@bazel/concatjs:index.bzl", "ts_library")
 load("//tools/node_tools/node_modules_licenses:node_modules_licenses.bzl", "node_modules_licenses")
 
 filegroup(
@@ -6,17 +6,18 @@
     srcs = glob(["licenses/*.txt"]),
 )
 
+# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
+# We would prefer to not depend on @bazel/concatjs ...
 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",
+        "@tools_npm//:node_modules",
     ],
 )
 
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index aabaca6..81c2f5e 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -86,6 +86,10 @@
 
 const packages: PackageInfo[] = [
   {
+    name: "@polymer/decorators",
+    license: SharedLicenses.Polymer2017,
+  },
+  {
     name: "@polymer/font-roboto",
     license: SharedLicenses.Polymer2015,
   },
@@ -261,26 +265,10 @@
     }
   },
   {
-    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
   },
@@ -307,14 +295,6 @@
   {
     name: "polymer-bridges",
     license: SharedLicenses.Polymer2018
-  },
-  {
-    name: "whatwg-fetch",
-    license: {
-      name: "whatwg-fetch",
-      type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
   }
 ];
 
diff --git a/polygerrit-ui/app/node_modules_licenses/tsconfig.json b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
index 6f4254f..c562a0c 100644
--- a/polygerrit-ui/app/node_modules_licenses/tsconfig.json
+++ b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
@@ -6,7 +6,7 @@
     "esModuleInterop": true,
     "strict": true,
     "moduleResolution": "node",
-    "outDir": "out",
+    "outDir": "../../../.ts-out/polygerrit-ui/node_modules_licenses", // Not used in bazel
     "types": ["node"]
   },
   "include": ["**/*.ts"]
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 2032430..a67bf4a 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -3,36 +3,34 @@
   "description": "Gerrit Code Review - Polygerrit dependencies",
   "browser": true,
   "dependencies": {
+    "@polymer/decorators": "^3.0.0",
     "@polymer/font-roboto-local": "^3.0.2",
     "@polymer/iron-a11y-keys-behavior": "^3.0.1",
-    "@polymer/iron-autogrow-textarea": "^3.0.1",
+    "@polymer/iron-a11y-announcer": "^3.0.1",
+    "@polymer/iron-autogrow-textarea": "^3.0.3",
     "@polymer/iron-dropdown": "^3.0.1",
-    "@polymer/iron-fit-behavior": "^3.0.1",
+    "@polymer/iron-fit-behavior": "^3.0.2",
     "@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-overlay-behavior": "^3.0.3",
     "@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-input": "^3.2.1",
     "@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",
+    "@polymer/polymer": "^3.4.1",
     "@webcomponents/shadycss": "^1.9.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
-    "es6-promise": "^3.3.1",
-    "moment": "^2.24.0",
+    "ba-linkify": "file:../../lib/ba-linkify/src/",
     "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"
+    "polymer-resin": "^2.0.1"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/app/polylint_test.sh b/polygerrit-ui/app/polylint_test.sh
index bc06c1c..0c7118d 100755
--- a/polygerrit-ui/app/polylint_test.sh
+++ b/polygerrit-ui/app/polylint_test.sh
@@ -5,7 +5,7 @@
 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
+cp -R -L polygerrit-ui/app/_pg_ts_out/* $TEST_TMPDIR
 
 #Can't use --root with polymer.json - see https://github.com/Polymer/tools/issues/2616
 #Change current directory to the root folder
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
index 411c969..11793e2 100644
--- a/polygerrit-ui/app/polymer.json
+++ b/polygerrit-ui/app/polymer.json
@@ -1,14 +1,14 @@
 {
-  "entrypoint": "elements/gr-app.html",
+  "shell": "elements/gr-app.js",
   "sources": [
-    "behaviors/**/*",
     "elements/**/*",
+    "mixins/**/*",
     "scripts/**/*",
     "styles/*",
     "types/**/*"
   ],
   "lint": {
-    "rules": ["polymer-2"],
+    "rules": ["polymer-3"],
     "ignoreWarnings": ["deprecated-dom-call"]
   }
 }
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index cfc8c01..d93b5ea 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -60,12 +60,6 @@
 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
@@ -87,7 +81,10 @@
   context: 'window',
   plugins: [resolve({
     customResolveOptions: {
-      moduleDirectory: 'external/ui_npm/node_modules'
+      // By default, it tries to use page.mjs file instead of page.js
+      // when importing 'page/page'.
+      extensions: ['.js'],
+      moduleDirectory: 'external/ui_npm/node_modules',
     }
   }), importLocalFontMetaUrlResolver()],
 };
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 9303f2b..feb1a82 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,5 +1,85 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+
+def _get_ts_compiled_path(outdir, file_name):
+    """Calculates the typescript output path for a file_name.
+
+    Args:
+      outdir: the typescript output directory (relative to polygerrit-ui/app/)
+      file_name: the original file name (relative to polygerrit-ui/app/)
+
+    Returns:
+      String - the path to the file produced by the typescript compiler
+    """
+    if file_name.endswith(".js"):
+        return outdir + "/" + file_name
+    if file_name.endswith(".ts"):
+        return outdir + "/" + file_name[:-2] + "js"
+    fail("The file " + file_name + " has unsupported extension")
+
+def _get_ts_output_files(outdir, srcs):
+    """Calculates the files paths produced by the typescript compiler
+
+    Args:
+      outdir: the typescript output directory (relative to polygerrit-ui/app/)
+      srcs: list of input files (all paths relative to polygerrit-ui/app/)
+
+    Returns:
+      List of strings
+    """
+    result = []
+    for f in srcs:
+        if f.endswith(".d.ts"):
+            continue
+        result.append(_get_ts_compiled_path(outdir, f))
+    return result
+
+def compile_ts(name, srcs, ts_outdir, include_tests = False):
+    """Compiles srcs files with the typescript compiler
+
+    Args:
+      name: rule name
+      srcs: list of input files (.js, .d.ts and .ts)
+      ts_outdir: typescript output directory
+
+    Returns:
+      The list of compiled files
+    """
+    ts_rule_name = name + "_ts_compiled"
+
+    # List of files produced by the typescript compiler
+    generated_js = _get_ts_output_files(ts_outdir, srcs)
+
+    all_srcs = srcs + [
+        ":tsconfig.json",
+        ":tsconfig_bazel.json",
+        "@ui_npm//:node_modules",
+    ]
+    ts_project = "tsconfig_bazel.json"
+
+    if include_tests:
+        all_srcs = all_srcs + [
+            ":tsconfig_bazel_test.json",
+            "@ui_dev_npm//:node_modules",
+        ]
+        ts_project = "tsconfig_bazel_test.json"
+
+    # Run the compiler
+    native.genrule(
+        name = ts_rule_name,
+        srcs = all_srcs,
+        outs = generated_js,
+        cmd = " && ".join([
+            "$(location //tools/node_tools:tsc-bin) --project $(location :" +
+            ts_project +
+            ") --outdir $(RULEDIR)/" +
+            ts_outdir +
+            " --baseUrl ./external/ui_npm/node_modules/",
+        ]),
+        tools = ["//tools/node_tools:tsc-bin"],
+    )
+
+    return generated_js
 
 def polygerrit_bundle(name, srcs, outs, entry_point):
     """Build .zip bundle from source code
@@ -8,10 +88,10 @@
         name: rule name
         srcs: source files
         outs: array with a single item - the output file name
-        entry_point: application entry-point
+        entry_point: application js entry-point
     """
 
-    app_name = entry_point.split(".html")[0].split("/").pop()  # eg: gr-app
+    app_name = entry_point.split(".js")[0].split("/").pop()  # eg: gr-app
 
     native.filegroup(
         name = app_name + "-full-src",
@@ -24,7 +104,7 @@
         name = app_name + "-bundle-js",
         srcs = [app_name + "-full-src"],
         config_file = ":rollup.config.js",
-        entry_point = "elements/" + app_name + ".js",
+        entry_point = entry_point,
         rollup_bin = "//tools/node_tools:rollup-bin",
         sourcemap = "hidden",
         deps = [
@@ -36,7 +116,6 @@
         name = name + "_app_sources",
         srcs = [
             app_name + "-bundle-js.js",
-            entry_point,
         ],
     )
 
@@ -46,15 +125,6 @@
     )
 
     native.filegroup(
-        name = name + "_theme_sources",
-        srcs = native.glob(
-            ["styles/themes/*.html"],
-            # app-theme.html already included via an import in gr-app.html.
-            exclude = ["styles/themes/app-theme.html"],
-        ),
-    )
-
-    native.filegroup(
         name = name + "_top_sources",
         srcs = [
             "favicon.ico",
@@ -68,7 +138,6 @@
         srcs = [
             name + "_app_sources",
             name + "_css_sources",
-            name + "_theme_sources",
             name + "_top_sources",
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
@@ -84,7 +153,6 @@
             "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",
             "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/",
@@ -94,62 +162,3 @@
             "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 5f61de7..0ceca3c 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -6,12 +6,11 @@
     bazel_bin=bazel
 fi
 
-# WCT tests are not hermetic, and need extra environment variables.
-# TODO(hanwen): does $DISPLAY even work on OSX?
+# At least temporarily we want to know what is going on even when all tests are
+# passing, so we have a better chance of debugging what happens in CI test runs
+# that were supposed to catch test failures, but did not.
 ${bazel_bin} test \
-      --test_env="HOME=$HOME" \
-      --test_env="WCT_ARGS=${WCT_ARGS}" \
-      --test_env="DISPLAY=${DISPLAY}" \
-      --test_env="WCT_HEADLESS_MODE=${WCT_HEADLESS_MODE}" \
       "$@" \
-      //polygerrit-ui/app:wct_test
+      --test_verbose_timeout_warnings \
+      --test_output=all \
+      //polygerrit-ui:karma_test
diff --git a/polygerrit-ui/app/samples/add-from-favorite-reviewers.js b/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
new file mode 100644
index 0000000..76b2787
--- /dev/null
+++ b/polygerrit-ui/app/samples/add-from-favorite-reviewers.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.
+ */
+/**
+ * This plugin will a button to quickly add favorite reviewers to
+ * reviewers in reply dialog.
+ */
+
+const onToggleButtonClicks = [];
+function toggleButtonClicked(expanded) {
+  onToggleButtonClicks.forEach(cb => {
+    cb(expanded);
+  });
+}
+
+class ReviewerShortcut extends Polymer.Element {
+  static get is() { return 'reviewer-shortcut'; }
+
+  static get properties() {
+    return {
+      change: Object,
+      expanded: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <button on-click="toggleControlContent">
+        [[computeButtonText(expanded)]]
+      </button>
+    `;
+  }
+
+  toggleControlContent() {
+    this.expanded = !this.expanded;
+    toggleButtonClicked(this.expanded);
+  }
+
+  computeButtonText(expanded) {
+    return expanded ? 'Collapse' : 'Add favorite reviewers';
+  }
+}
+
+customElements.define(ReviewerShortcut.is, ReviewerShortcut);
+
+class ReviewerShortcutContent extends Polymer.Element {
+  static get is() { return 'reviewer-shortcut-content'; }
+
+  static get properties() {
+    return {
+      change: Object,
+      hidden: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host([hidden]) {
+        display: none;
+      }
+      :host {
+        display: block;
+      }
+      </style>
+      <ul>
+        <li><button on-click="addApple">Apple</button></li>
+        <li><button on-click="addBanana">Banana</button></li>
+        <li><button on-click="addCherry">Cherry</button></li>
+      </ul>
+    `;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    onToggleButtonClicks.push(expanded => {
+      this.hidden = !expanded;
+    });
+  }
+
+  addApple() {
+    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
+      reviewer: {
+        display_name: 'Apple',
+        email: 'apple@gmail.com',
+        name: 'Apple',
+        _account_id: 0,
+      },
+    },
+    composed: true, bubbles: true}));
+  }
+
+  addBanana() {
+    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
+      reviewer: {
+        display_name: 'Banana',
+        email: 'banana@gmail.com',
+        name: 'B',
+        _account_id: 1,
+      },
+    },
+    composed: true, bubbles: true}));
+  }
+
+  addCherry() {
+    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
+      reviewer: {
+        display_name: 'Cherry',
+        email: 'cherry@gmail.com',
+        name: 'C',
+        _account_id: 2,
+      },
+    },
+    composed: true, bubbles: true}));
+  }
+}
+
+customElements.define(ReviewerShortcutContent.is, ReviewerShortcutContent);
+
+Gerrit.install(plugin => {
+  plugin.registerCustomComponent(
+      'reply-reviewers', ReviewerShortcut.is, {slot: 'right'});
+  plugin.registerCustomComponent(
+      'reply-reviewers', ReviewerShortcutContent.is, {slot: 'below'});
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/bind-parameters.js b/polygerrit-ui/app/samples/bind-parameters.js
index 8f08e27..30c7c3d 100644
--- a/polygerrit-ui/app/samples/bind-parameters.js
+++ b/polygerrit-ui/app/samples/bind-parameters.js
@@ -14,9 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const {Element, html} = Polymer;
 
-class MyBindSample extends Element {
+// Element class exists in all browsers:
+// https://developer.mozilla.org/en-US/docs/Web/API/Element
+// Rename it to PolymerElement to avoid conflicts. Also,
+// typescript reports the following error:
+// error TS2451: Cannot redeclare block-scoped variable 'Element'.
+const {html, Element: PolymerElement} = Polymer;
+
+class MyBindSample extends PolymerElement {
   static get is() { return 'my-bind-sample'; }
 
   static get properties() {
@@ -45,7 +51,7 @@
   }
 
   _onRevisionChanged(value) {
-    console.log(`(attributeHelper.bind) revision number: ${value._number}`);
+    console.info(`(attributeHelper.bind) revision number: ${value._number}`);
   }
 }
 
@@ -62,4 +68,4 @@
   // 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/custom-wip-requirement.js b/polygerrit-ui/app/samples/custom-wip-requirement.js
new file mode 100644
index 0000000..1d2663c
--- /dev/null
+++ b/polygerrit-ui/app/samples/custom-wip-requirement.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.
+ */
+
+/**
+ * This plugin will add a text next to WIP requirement if shown.
+ */
+class WipRequirementValue extends Polymer.Element {
+  static get is() {
+    return 'wip-requirement-value';
+  }
+
+  static get template() {
+    return Polymer.html`
+        <style include="shared-styles">
+        :host {
+          color: var(--deemphasized-text-color);
+        }
+        </style>
+        <span>Will be removed once active.</span>
+      `;
+  }
+
+  static get properties() {
+    return {
+      change: Object,
+      requirement: Object,
+    };
+  }
+}
+
+customElements.define(WipRequirementValue.is, WipRequirementValue);
+
+Gerrit.install(plugin => {
+  plugin.registerCustomComponent(
+      'submit-requirement-item-wip', WipRequirementValue.is, {slot: 'value'});
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/extra-column-on-file-list.js b/polygerrit-ui/app/samples/extra-column-on-file-list.js
new file mode 100644
index 0000000..2e37c01
--- /dev/null
+++ b/polygerrit-ui/app/samples/extra-column-on-file-list.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.
+ */
+/**
+ * This plugin will an extra column to file list on change page to show
+ * the first character of the path.
+ */
+
+// Header of this extra column
+class ColumnHeader extends Polymer.Element {
+  static get is() { return 'column-header'; }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host {
+        display: block;
+        padding-right: var(--spacing-m);
+        min-width: 5em;
+      }
+      </style>
+      <div>First Char</div>
+    `;
+  }
+}
+
+customElements.define(ColumnHeader.is, ColumnHeader);
+
+// Content of this extra column
+class ColumnContent extends Polymer.Element {
+  static get is() { return 'column-content'; }
+
+  static get properties() {
+    return {
+      path: String,
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host {
+        display:block;
+        padding-right: var(--spacing-m);
+        min-width: 5em;
+      }
+      </style>
+      <div>[[getStatus(path)]]</div>
+    `;
+  }
+
+  getStatus(path) {
+    return path.charAt(0);
+  }
+}
+
+customElements.define(ColumnContent.is, ColumnContent);
+
+Gerrit.install(plugin => {
+  plugin.registerDynamicCustomComponent(
+      'change-view-file-list-header-prepend', ColumnHeader.is);
+  plugin.registerDynamicCustomComponent(
+      'change-view-file-list-content-prepend', ColumnContent.is);
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
index 00f95f5..4f64059 100644
--- a/polygerrit-ui/app/samples/repo-command.js
+++ b/polygerrit-ui/app/samples/repo-command.js
@@ -14,9 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const {Element, html} = Polymer;
 
-class RepoCommandLow extends Element {
+// Element class exists in all browsers:
+// https://developer.mozilla.org/en-US/docs/Web/API/Element
+// Rename it to PolymerElement to avoid conflicts. Also,
+// typescript reports the following error:
+// error TS2451: Cannot redeclare block-scoped variable 'Element'.
+const {html, Element: PolymerElement} = Polymer;
+
+class RepoCommandLow extends PolymerElement {
   static get is() { return 'repo-command-low'; }
 
   static get properties() {
@@ -27,17 +33,25 @@
 
   static get template() {
     return html`
-    <gr-repo-command
-      title="Low-level bork"
-      on-command-tap="_handleCommandTap">
-    </gr-repo-command>
-    `;
+    <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+    </style>
+    <h3>Low-level bork</h3>
+    <gr-button
+      on-click="_handleCommandTap"
+    >
+      Low-level bork
+    </gr-button>
+   `;
   }
 
   connectedCallback() {
     super.connectedCallback();
-    console.log(this.repoName);
-    console.log(this.config);
+    console.info(this.repoName);
+    console.info(this.config);
     this.hidden = this.repoName !== 'All-Projects';
   }
 
@@ -70,4 +84,4 @@
   // 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.js b/polygerrit-ui/app/samples/some-screen.js
index 09acc81..c600fe4 100644
--- a/polygerrit-ui/app/samples/some-screen.js
+++ b/polygerrit-ui/app/samples/some-screen.js
@@ -14,9 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const {Element, html} = Polymer;
 
-class SomeScreenMain extends Element {
+// Element class exists in all browsers:
+// https://developer.mozilla.org/en-US/docs/Web/API/Element
+// Rename it to PolymerElement to avoid conflicts. Also,
+// typescript reports the following error:
+// error TS2451: Cannot redeclare block-scoped variable 'Element'.
+const {html, Element: PolymerElement} = Polymer;
+
+class SomeScreenMain extends PolymerElement {
   static get is() { return 'some-screen-main'; }
 
   static get properties() {
@@ -64,4 +70,4 @@
   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/theme-plugin.js b/polygerrit-ui/app/samples/theme-plugin.js
index f3a8931..b3d4033 100644
--- a/polygerrit-ui/app/samples/theme-plugin.js
+++ b/polygerrit-ui/app/samples/theme-plugin.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 const customTheme = document.createElement('dom-module');
-customTheme.id = 'theme-plugin';
 customTheme.innerHTML = `
   <template>
     <style>
@@ -25,9 +24,9 @@
     </style>
   </template>
 `;
+customTheme.register('theme-plugin');
 
 const darkCustomTheme = document.createElement('dom-module');
-darkCustomTheme.id = 'dark-theme-plugin';
 darkCustomTheme.innerHTML = `
   <template>
     <style>
@@ -37,6 +36,7 @@
     </style>
   </template>
 `;
+darkCustomTheme.register('dark-theme-plugin');
 
 /**
  * This plugin will change the primary text color to red.
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.js b/polygerrit-ui/app/scripts/bundled-polymer.js
deleted file mode 100644
index 711d587..0000000
--- a/polygerrit-ui/app/scripts/bundled-polymer.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// 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/bundled-polymer.ts b/polygerrit-ui/app/scripts/bundled-polymer.ts
new file mode 100644
index 0000000..a52cc6b
--- /dev/null
+++ b/polygerrit-ui/app/scripts/bundled-polymer.ts
@@ -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.
+ */
+
+// 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 plugins
+// expects that Polymer is available we must setup all Polymer global
+// variables
+//
+// 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 './js/bundled-polymer-bridges';
+
+import {importHref} from './import-href';
+
+window.Polymer = window.Polymer || {};
+window.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
deleted file mode 100644
index 62dc3ee..0000000
--- a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
+++ /dev/null
@@ -1,71 +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.
- */
-const ANONYMOUS_NAME = 'Anonymous';
-
-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;
-  }
-
-  static getDisplayName(config, account) {
-    if (account && account.display_name) {
-      return account.display_name;
-    }
-    if (!account || !account.name || !config || !config.accounts) {
-      return this.getUserName(config, account);
-    }
-    if (config.accounts.default_display_name === 'USERNAME'
-        && account.username) {
-      return account.username;
-    }
-    if (config.accounts.default_display_name === 'FIRST_NAME') {
-      return account.name.trim().split(' ')[0];
-    }
-    // Treat every other value as FULL_NAME.
-    return account.name;
-  }
-
-  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
deleted file mode 100644
index 818ddaa..0000000
--- a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
+++ /dev/null
@@ -1,203 +0,0 @@
-<!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-display-name-utils</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 {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 = {
-      accounts: {
-        default_display_name: 'USERNAME',
-      },
-    };
-    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
deleted file mode 100644
index 2d2deac..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {GrDisplayNameUtils} from '../gr-display-name-utils/gr-display-name-utils.js';
-
-export 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),
-      value: {account, count: 1},
-    };
-  }
-}
-
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
new file mode 100644
index 0000000..e555ebe
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
@@ -0,0 +1,39 @@
+/**
+ * @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 {getAccountDisplayName} from '../../utils/display-name-util';
+import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {AccountInfo} from '../../types/common';
+
+export class GrEmailSuggestionsProvider {
+  constructor(private _restAPI: RestApiService) {}
+
+  getSuggestions(input: string) {
+    return this._restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
+      if (!accounts) {
+        return [];
+      }
+      return accounts;
+    });
+  }
+
+  makeSuggestionItem(account: AccountInfo) {
+    return {
+      name: getAccountDisplayName(undefined, 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
deleted file mode 100644
index 80d3590..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
+++ /dev/null
@@ -1,97 +0,0 @@
-<!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-email-suggestions-provider</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-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<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';
-
-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,
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    restAPI = fixture('basic');
-    provider = new GrEmailSuggestionsProvider(restAPI);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  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,
-      },
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
new file mode 100644
index 0000000..7c40b7a
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`);
+
+suite('GrEmailSuggestionsProvider tests', () => {
+  let restAPI;
+  let provider;
+  const account1 = {
+    name: 'Some name',
+    email: 'some@example.com',
+  };
+  const account2 = {
+    email: 'other@example.com',
+    _account_id: 3,
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    restAPI = basicFixture.instantiate();
+    provider = new GrEmailSuggestionsProvider(restAPI);
+  });
+
+  test('getSuggestions', done => {
+    const getSuggestedAccountsStub =
+        sinon.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,
+      },
+    });
+  });
+});
+
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
deleted file mode 100644
index 16b6aae..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
+++ /dev/null
@@ -1,36 +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.
- */
-
-export 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 => Object.assign({}, groups[key], {name: key}));
-        });
-  }
-
-  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.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
new file mode 100644
index 0000000..df77c76
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
@@ -0,0 +1,42 @@
+/**
+ * @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 {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {GroupBaseInfo} from '../../types/common';
+
+export class GrGroupSuggestionsProvider {
+  constructor(private _restAPI: RestApiService) {}
+
+  getSuggestions(input: string) {
+    return this._restAPI.getSuggestedGroups(`${input}`).then(groups => {
+      if (!groups) {
+        return [];
+      }
+      const keys = Object.keys(groups);
+      return keys.map(key => {
+        return {...groups[key], name: key};
+      });
+    });
+  }
+
+  makeSuggestionItem(suggestion: GroupBaseInfo) {
+    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
deleted file mode 100644
index 2111b7e..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
+++ /dev/null
@@ -1,105 +0,0 @@
-<!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-group-suggestions-provider</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-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<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';
-
-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',
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    restAPI = fixture('basic');
-    provider = new GrGroupSuggestionsProvider(restAPI);
-  });
-
-  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'},
-            }));
-
-    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-group-suggestions-provider/gr-group-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
new file mode 100644
index 0000000..0939f76
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`);
+
+suite('GrGroupSuggestionsProvider tests', () => {
+  let restAPI;
+  let provider;
+  const group1 = {
+    name: 'Some name',
+    id: 1,
+  };
+  const group2 = {
+    name: 'Other name',
+    id: 3,
+    url: 'abcd',
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    restAPI = basicFixture.instantiate();
+    provider = new GrGroupSuggestionsProvider(restAPI);
+  });
+
+  test('getSuggestions', done => {
+    const getSuggestedAccountsStub =
+        sinon.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,
+        },
+      },
+    });
+  });
+});
+
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
deleted file mode 100644
index 3a47ed3..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {GrDisplayNameUtils} from '../gr-display-name-utils/gr-display-name-utils.js';
-
-/**
- * @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}`);
-    }
-  }
-
-  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;
-        });
-    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),
-        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.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
new file mode 100644
index 0000000..6ab69bb
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -0,0 +1,142 @@
+/**
+ * @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 {
+  getAccountDisplayName,
+  getGroupDisplayName,
+} from '../../utils/display-name-util';
+import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AccountInfo,
+  isReviewerAccountSuggestion,
+  isReviewerGroupSuggestion,
+  NumericChangeId,
+  ServerInfo,
+  SuggestedReviewerInfo,
+  Suggestion,
+} from '../../types/common';
+import {assertNever} from '../../utils/common-util';
+
+// TODO(TS): enum name doesn't follow typescript style guid rules
+// Rename it
+export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
+  REVIEWER = 'reviewers',
+  CC = 'ccs',
+  ANY = 'any',
+}
+
+export function isAccountSuggestions(s: Suggestion): s is AccountInfo {
+  return (s as AccountInfo)._account_id !== undefined;
+}
+
+type ApiCallCallback = (input: string) => Promise<Suggestion[] | void>;
+
+export interface SuggestionItem {
+  name: string;
+  value: SuggestedReviewerInfo;
+}
+
+export class GrReviewerSuggestionsProvider {
+  static create(
+    restApi: RestApiService,
+    changeNumber: NumericChangeId,
+    userType: SUGGESTIONS_PROVIDERS_USERS_TYPES
+  ) {
+    switch (userType) {
+      case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
+        return new GrReviewerSuggestionsProvider(restApi, input =>
+          restApi.getChangeSuggestedReviewers(changeNumber, input)
+        );
+      case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
+        return new GrReviewerSuggestionsProvider(restApi, input =>
+          restApi.getChangeSuggestedCCs(changeNumber, input)
+        );
+      case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
+        return new GrReviewerSuggestionsProvider(restApi, input =>
+          restApi.getSuggestedAccounts(`cansee:${changeNumber} ${input}`)
+        );
+      default:
+        throw new Error(`Unknown users type: ${userType}`);
+    }
+  }
+
+  private _initPromise?: Promise<void>;
+
+  private _config?: ServerInfo;
+
+  private _loggedIn = false;
+
+  private _initialized = false;
+
+  private constructor(
+    private readonly _restAPI: RestApiService,
+    private readonly _apiCall: ApiCallCallback
+  ) {}
+
+  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;
+    });
+    return this._initPromise;
+  }
+
+  getSuggestions(input: string): Promise<Suggestion[]> {
+    if (!this._initialized || !this._loggedIn) {
+      return Promise.resolve([]);
+    }
+
+    return this._apiCall(input).then(reviewers => reviewers || []);
+  }
+
+  makeSuggestionItem(suggestion: Suggestion): SuggestionItem {
+    if (isReviewerAccountSuggestion(suggestion)) {
+      // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+      return {
+        name: getAccountDisplayName(this._config, suggestion.account),
+        value: suggestion,
+      };
+    }
+
+    if (isReviewerGroupSuggestion(suggestion)) {
+      // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+      return {
+        name: getGroupDisplayName(suggestion.group),
+        value: suggestion,
+      };
+    }
+
+    if (isAccountSuggestions(suggestion)) {
+      // Reviewer is an account suggestion from getSuggestedAccounts.
+      return {
+        name: getAccountDisplayName(this._config, suggestion),
+        value: {account: suggestion, count: 1},
+      };
+    }
+    assertNever(suggestion, 'Received an incorrect suggestion');
+  }
+}
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
deleted file mode 100644
index 8774d48..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
+++ /dev/null
@@ -1,261 +0,0 @@
-<!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-reviewer-suggestions-provider</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-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<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 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',
-      },
-    };
-
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getConfig() { return Promise.resolve({}); },
-    });
-
-    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 => {
-      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]);
-          },
-        });
-      });
-
-      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},
-        });
-
-        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},
-        });
-      });
-
-      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;
-        return provider.getSuggestions('').then(() => {
-          assert.isFalse(xhrSpy.called);
-          provider._loggedIn = true;
-          return provider.getSuggestions('').then(() => {
-            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,
-          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/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
new file mode 100644
index 0000000..fe13c1c
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
@@ -0,0 +1,243 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`);
+
+suite('GrReviewerSuggestionsProvider tests', () => {
+  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 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',
+      },
+    };
+
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getConfig() { return Promise.resolve({}); },
+    });
+
+    restAPI = basicFixture.instantiate();
+    change = {
+      _number: 42,
+      owner,
+      reviewers: {
+        CC: [existingReviewer1],
+        REVIEWER: [existingReviewer2],
+      },
+    };
+
+    return flush(done);
+  });
+
+  suite('allowAnyUser set to false', () => {
+    setup(done => {
+      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]);
+          },
+        });
+      });
+
+      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},
+        });
+
+        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},
+        });
+
+        account3.email = undefined;
+
+        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;
+        return provider.getSuggestions('').then(() => {
+          assert.isFalse(xhrSpy.called);
+          provider._loggedIn = true;
+          return provider.getSuggestions('').then(() => {
+            assert.isTrue(xhrSpy.called);
+          });
+        });
+      });
+    });
+
+    test('getChangeSuggestedReviewers is used', done => {
+      const suggestReviewerStub =
+          sinon.stub(restAPI, 'getChangeSuggestedReviewers')
+              .returns(Promise.resolve([]));
+      const suggestAccountStub =
+          sinon.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,
+          SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
+      provider.init().then(done);
+    });
+
+    test('getSuggestedAccounts is used', done => {
+      const suggestReviewerStub =
+          sinon.stub(restAPI, 'getChangeSuggestedReviewers')
+              .returns(Promise.resolve([]));
+      const suggestAccountStub =
+          sinon.stub(restAPI, 'getSuggestedAccounts')
+              .returns(Promise.resolve([]));
+
+      provider.getSuggestions('').then(() => {
+        assert.isFalse(suggestReviewerStub.called);
+        assert.isTrue(suggestAccountStub.calledOnce);
+        assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
+        done();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.js b/polygerrit-ui/app/scripts/hiddenscroll.js
deleted file mode 100644
index a580b05..0000000
--- a/polygerrit-ui/app/scripts/hiddenscroll.js
+++ /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.
- */
-
-let 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();
-});
-
-export function _setHiddenScroll(value) {
-  hiddenscroll = value;
-}
-
-export function getHiddenScroll() {
-  return hiddenscroll;
-}
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.ts b/polygerrit-ui/app/scripts/hiddenscroll.ts
new file mode 100644
index 0000000..b4364be
--- /dev/null
+++ b/polygerrit-ui/app/scripts/hiddenscroll.ts
@@ -0,0 +1,34 @@
+/**
+ * @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.
+ */
+
+let hiddenscroll: boolean | undefined = 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();
+});
+
+export function _setHiddenScroll(value: boolean) {
+  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
deleted file mode 100644
index 6ff40a5..0000000
--- a/polygerrit-ui/app/scripts/import-href.js
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// 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/import-href.ts b/polygerrit-ui/app/scripts/import-href.ts
new file mode 100644
index 0000000..3249c56
--- /dev/null
+++ b/polygerrit-ui/app/scripts/import-href.ts
@@ -0,0 +1,119 @@
+/**
+ * @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.
+
+interface ImportHrefElement extends HTMLLinkElement {
+  __dynamicImportLoaded?: boolean;
+}
+
+// run a callback when HTMLImports are ready or immediately if
+// this api is not available.
+function whenImportsReady(cb: () => void) {
+  const win = window as Window;
+  if (win.HTMLImports) {
+    win.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 href URL to document to load.
+ * @param onload Callback to notify when an import successfully
+ *   loaded.
+ * @param onerror Callback to notify when an import
+ *   unsuccessfully loaded.
+ * @param async True if the import should be loaded `async`.
+ *   Defaults to `false`.
+ * @return The link element for the URL to be loaded.
+ */
+export function importHref(
+  href: string,
+  onload: (e: Event) => void,
+  onerror: (e: Event) => void,
+  async = false
+): HTMLLinkElement {
+  let link = document.head.querySelector(
+    'link[href="' + href + '"][import-href]'
+  ) as ImportHrefElement;
+  if (!link) {
+    link = document.createElement('link') as ImportHrefElement;
+    link.setAttribute('rel', 'import');
+    link.setAttribute('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 (async) {
+    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: 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: 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/js/bundled-polymer-bridges.d.ts b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
new file mode 100644
index 0000000..7041300
--- /dev/null
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
@@ -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.
+ */
+
+// We can't convert bundled-polymer.js to ts. To allow import
+// bundled-polymer.js from .ts files we should add this .d.ts file
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
new file mode 100644
index 0000000..d04b533
--- /dev/null
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.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.
+ */
+
+// This file can't be converted to TS - it imports some .js file which
+// can't be imported into typescript
+
+// 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 plugins
+// expects that Polymer is available we must setup all Polymer global
+// variables
+//
+// 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';
+
+// This is needed due to the Polymer.IronFocusablesHelper in gr-overlay.ts
+import 'polymer-bridges/iron-overlay-behavior/iron-focusables-helper_bridge.js';
+
diff --git a/polygerrit-ui/app/scripts/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts
new file mode 100644
index 0000000..ee03171
--- /dev/null
+++ b/polygerrit-ui/app/scripts/polymer-resin-install.ts
@@ -0,0 +1,72 @@
+/**
+ * @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 'polymer-resin/standalone/polymer-resin';
+
+export type SafeTypeBridge = (
+  value: unknown,
+  type: string,
+  fallback: unknown
+) => unknown;
+
+export type ReportHandler = (
+  isDisallowedValue: boolean,
+  printfFormatString: string,
+  ...printfArgs: unknown[]
+) => void;
+
+declare global {
+  interface Window {
+    security: {
+      polymer_resin: {
+        SafeType: {
+          CONSTANT: string;
+          HTML: string;
+          JAVASCRIPT: string;
+          RESOURCE_URL: string;
+          /** Unprivileged but possibly wrapped string. */
+          STRING: string;
+          STYLE: string;
+          URL: string;
+        };
+        CONSOLE_LOGGING_REPORT_HANDLER: ReportHandler;
+        install(options: {
+          UNSAFE_passThruDisallowedValues?: boolean;
+          allowedIdentifierPrefixes?: string[];
+          reportHandler?: ReportHandler;
+          safeTypesBridge?: SafeTypeBridge;
+        }): void;
+      };
+    };
+  }
+}
+
+const security = window.security;
+
+export const _testOnly_defaultResinReportHandler =
+  security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
+
+export function installPolymerResin(
+  safeTypesBridge: SafeTypeBridge,
+  reportHandler = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER
+) {
+  window.security.polymer_resin.install({
+    allowedIdentifierPrefixes: [''],
+    reportHandler,
+    safeTypesBridge,
+  });
+}
diff --git a/polygerrit-ui/app/scripts/rootElement.js b/polygerrit-ui/app/scripts/rootElement.js
deleted file mode 100644
index 4900ba2..0000000
--- a/polygerrit-ui/app/scripts/rootElement.js
+++ /dev/null
@@ -1,18 +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.
- */
-
-export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/rootElement.ts b/polygerrit-ui/app/scripts/rootElement.ts
new file mode 100644
index 0000000..2217bf9
--- /dev/null
+++ b/polygerrit-ui/app/scripts/rootElement.ts
@@ -0,0 +1,21 @@
+/**
+ * @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.
+ */
+
+/**
+ * Returns the root element of the dom: body.
+ */
+export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
deleted file mode 100644
index 46fa1ad..0000000
--- a/polygerrit-ui/app/scripts/util.js
+++ /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.
- */
-
-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');
-  },
-
-  getCookie(name) {
-    const key = name + '=';
-    const cookies = document.cookie.split(';');
-    for (let i = 0; i < cookies.length; i++) {
-      let c = cookies[i];
-      while (c.charAt(0) === ' ') {
-        c = c.substring(1);
-      }
-      if (c.startsWith(key)) {
-        return c.substring(key.length, c.length);
-      }
-    }
-    return '';
-  },
-
-  /**
-   * Make the promise cancelable.
-   *
-   * Returns a promise with a `cancel()` method wrapped around `promise`.
-   * Calling `cancel()` will reject the returned promise with
-   * {isCancelled: true} synchronously. If the inner promise for a cancelled
-   * promise resolves or rejects this is ignored.
-   */
-  makeCancelable: promise => {
-    // True if the promise is either resolved or reject (possibly cancelled)
-    let isDone = false;
-
-    let rejectPromise;
-
-    const wrappedPromise = new Promise((resolve, reject) => {
-      rejectPromise = reject;
-      promise.then(val => {
-        if (!isDone) resolve(val);
-        isDone = true;
-      }, error => {
-        if (!isDone) reject(error);
-        isDone = true;
-      });
-    });
-
-    wrappedPromise.cancel = () => {
-      if (isDone) return;
-      rejectPromise({isCanceled: true});
-      isDone = true;
-    };
-    return wrappedPromise;
-  },
-
-  /**
-   * Get computed style value.
-   *
-   * If ShadyCSS is provided, use ShadyCSS api.
-   * If `getComputedStyleValue` is provided on the elment, use it.
-   * Otherwise fallback to native method (in polymer 2).
-   *
-   */
-  getComputedStyleValue: (name, el) => {
-    let style;
-    if (window.ShadyCSS) {
-      style = ShadyCSS.getComputedStyleValue(el, name);
-    } else if (el.getComputedStyleValue) {
-      style = el.getComputedStyleValue(name);
-    } else {
-      style = getComputedStyle(el).getPropertyValue(name);
-    }
-    return style;
-  },
-
-  /**
-   * Query selector on a dom element.
-   *
-   * This is shadow DOM compatible, but only works when selector is within
-   * one shadow host, won't work if your selector is crossing
-   * multiple shadow hosts.
-   *
-   */
-  querySelector: (el, selector) => {
-    let nodes = [el];
-    let result = null;
-    while (nodes.length) {
-      const node = nodes.pop();
-
-      // Skip if it's an invalid node.
-      if (!node || !node.querySelector) continue;
-
-      // Try find it with native querySelector directly
-      result = node.querySelector(selector);
-
-      if (result) {
-        break;
-      }
-
-      // 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);
-      }
-    }
-    return result;
-  },
-
-  /**
-   * Query selector all dom elements matching with certain selector.
-   *
-   * This is shadow DOM compatible, but only works when selector is within
-   * one shadow host, won't work if your selector is crossing
-   * multiple shadow hosts.
-   *
-   * Note: this can be very expensive, only use when have to.
-   */
-  querySelectorAll: (el, selector) => {
-    let nodes = [el];
-    const results = new Set();
-    while (nodes.length) {
-      const node = nodes.pop();
-
-      if (!node || !node.querySelectorAll) continue;
-
-      // Try find all from regular children
-      [...node.querySelectorAll(selector)]
-          .forEach(el => results.add(el));
-
-      // Add all nodes with shadowRoot and loop through
-      const allShadowNodes = [...node.querySelectorAll('*')]
-          .filter(child => !!child.shadowRoot)
-          .map(child => child.shadowRoot);
-      nodes = nodes.concat(allShadowNodes);
-
-      // Add shadowRoot of current node if has one
-      // as its not included in node.querySelectorAll('*')
-      if (node.shadowRoot) {
-        nodes.push(node.shadowRoot);
-      }
-    }
-    return [...results];
-  },
-
-  /**
-   * 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.ts b/polygerrit-ui/app/scripts/util.ts
new file mode 100644
index 0000000..bf7120f
--- /dev/null
+++ b/polygerrit-ui/app/scripts/util.ts
@@ -0,0 +1,77 @@
+/**
+ * @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.
+ */
+
+export interface CancelablePromise<T> extends Promise<T> {
+  cancel(): void;
+}
+
+// TODO (dmfilippov): Each function must be exported separately. According to
+// the code style guide, a namespacing is not allowed.
+export const util = {
+  getCookie(name: string) {
+    const key = name + '=';
+    const cookies = document.cookie.split(';');
+    for (let i = 0; i < cookies.length; i++) {
+      let c = cookies[i];
+      while (c.charAt(0) === ' ') {
+        c = c.substring(1);
+      }
+      if (c.startsWith(key)) {
+        return c.substring(key.length, c.length);
+      }
+    }
+    return '';
+  },
+
+  /**
+   * Make the promise cancelable.
+   *
+   * Returns a promise with a `cancel()` method wrapped around `promise`.
+   * Calling `cancel()` will reject the returned promise with
+   * {isCancelled: true} synchronously. If the inner promise for a cancelled
+   * promise resolves or rejects this is ignored.
+   */
+  makeCancelable<T>(promise: Promise<T>) {
+    // True if the promise is either resolved or reject (possibly cancelled)
+    let isDone = false;
+
+    let rejectPromise: (reason?: unknown) => void;
+
+    const wrappedPromise: CancelablePromise<T> = new Promise(
+      (resolve, reject) => {
+        rejectPromise = reject;
+        promise.then(
+          val => {
+            if (!isDone) resolve(val);
+            isDone = true;
+          },
+          error => {
+            if (!isDone) reject(error);
+            isDone = true;
+          }
+        );
+      }
+    ) as CancelablePromise<T>;
+
+    wrappedPromise.cancel = () => {
+      if (isDone) return;
+      rejectPromise({isCanceled: true});
+      isDone = true;
+    };
+    return wrappedPromise;
+  },
+};
diff --git a/polygerrit-ui/app/scripts/util_test.html b/polygerrit-ui/app/scripts/util_test.html
deleted file mode 100644
index a3893d2..0000000
--- a/polygerrit-ui/app/scripts/util_test.html
+++ /dev/null
@@ -1,89 +0,0 @@
-<!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/app-context-init.js b/polygerrit-ui/app/services/app-context-init.js
deleted file mode 100644
index 1c32eee..0000000
--- a/polygerrit-ui/app/services/app-context-init.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {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.ts b/polygerrit-ui/app/services/app-context-init.ts
new file mode 100644
index 0000000..b249d16
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -0,0 +1,69 @@
+/**
+ * @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, AppContext} from './app-context';
+import {FlagsServiceImplementation} from './flags/flags_impl';
+import {GrReporting} from './gr-reporting/gr-reporting_impl';
+import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
+import {Auth} from './gr-auth/gr-auth_impl';
+
+type ServiceName = keyof AppContext;
+type ServiceCreator<T> = () => T;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const initializedServices: Map<ServiceName, any> = new Map();
+
+function getService<K extends ServiceName>(
+  serviceName: K,
+  serviceCreator: ServiceCreator<AppContext[K]>
+): AppContext[K] {
+  if (!initializedServices.has(serviceName)) {
+    initializedServices.set(serviceName, serviceCreator());
+  }
+  return initializedServices.get(serviceName);
+}
+
+/**
+ * The AppContext lazy initializator for all services
+ */
+export function initAppContext() {
+  function populateAppContext(
+    serviceCreators: {[P in ServiceName]: ServiceCreator<AppContext[P]>}
+  ) {
+    const registeredServices = Object.keys(serviceCreators).reduce(
+      (registeredServices, key) => {
+        const serviceName = key as ServiceName;
+        const serviceCreator = serviceCreators[serviceName];
+        registeredServices[serviceName] = {
+          configurable: true, // Tests can mock properties
+          get() {
+            return getService(serviceName, serviceCreator);
+          },
+        };
+        return registeredServices;
+      },
+      {} as PropertyDescriptorMap
+    );
+    Object.defineProperties(appContext, registeredServices);
+  }
+
+  populateAppContext({
+    flagsService: () => new FlagsServiceImplementation(),
+    reportingService: () => new GrReporting(appContext.flagsService),
+    eventEmitter: () => new EventEmitter(),
+    authService: () => new Auth(appContext.eventEmitter),
+  });
+}
diff --git a/polygerrit-ui/app/services/app-context-init_test.html b/polygerrit-ui/app/services/app-context-init_test.html
deleted file mode 100644
index f5dc7d1..0000000
--- a/polygerrit-ui/app/services/app-context-init_test.html
+++ /dev/null
@@ -1,43 +0,0 @@
-<!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-init_test.js b/polygerrit-ui/app/services/app-context-init_test.js
new file mode 100644
index 0000000..9d22ec2
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context-init_test.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 '../test/common-test-setup-karma.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);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/services/app-context.js b/polygerrit-ui/app/services/app-context.js
deleted file mode 100644
index e10ced5..0000000
--- a/polygerrit-ui/app/services/app-context.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * 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/app-context.ts b/polygerrit-ui/app/services/app-context.ts
new file mode 100644
index 0000000..c08ee7a
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -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 {FlagsService} from './flags/flags';
+import {EventEmitterService} from './gr-event-interface/gr-event-interface';
+import {ReportingService} from './gr-reporting/gr-reporting';
+import {AuthService} from './gr-auth/gr-auth';
+
+export interface AppContext {
+  flagsService: FlagsService;
+  reportingService: ReportingService;
+  eventEmitter: EventEmitterService;
+  authService: AuthService;
+}
+
+/**
+ * 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
+ *
+ * It is guaranteed that all fields in appContext are always initialized
+ * (except for shared gr-diff)
+ */
+export const appContext: AppContext = {} as AppContext;
diff --git a/polygerrit-ui/app/services/flags.js b/polygerrit-ui/app/services/flags.js
deleted file mode 100644
index 8f04f4a..0000000
--- a/polygerrit-ui/app/services/flags.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * 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/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
new file mode 100644
index 0000000..047e9e0
--- /dev/null
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -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.
+ */
+
+export interface FlagsService {
+  isEnabled(experimentId: string): boolean;
+  enabledExperiments: string[];
+}
+
+/**
+ * @desc Experiment ids used in Gerrit.
+ */
+export enum KnownExperimentId {
+  PATCHSET_COMMENTS = 'UiFeature__patchset_comments',
+  PATCHSET_CHOICE_FOR_COMMENT_LINKS = 'UiFeature__patchset_choice_for_comment_links',
+  NEW_CONTEXT_CONTROLS = 'UiFeature__new_context_controls',
+}
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
new file mode 100644
index 0000000..fbfa833
--- /dev/null
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -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 {FlagsService} from './flags';
+
+declare global {
+  interface Window {
+    ENABLED_EXPERIMENTS: string[];
+  }
+}
+
+/**
+ * Flags service.
+ *
+ * Provides all related methods / properties regarding on feature flags.
+ */
+export class FlagsServiceImplementation implements FlagsService {
+  private readonly _experiments: Set<string>;
+
+  constructor() {
+    // stores all enabled experiments
+    this._experiments = this._loadExperiments();
+  }
+
+  isEnabled(experimentId: string): boolean {
+    return this._experiments.has(experimentId);
+  }
+
+  _loadExperiments(): Set<string> {
+    return new Set(window.ENABLED_EXPERIMENTS);
+  }
+
+  get enabledExperiments() {
+    return [...this._experiments];
+  }
+}
diff --git a/polygerrit-ui/app/services/flags/flags_test.js b/polygerrit-ui/app/services/flags/flags_test.js
new file mode 100644
index 0000000..33508af
--- /dev/null
+++ b/polygerrit-ui/app/services/flags/flags_test.js
@@ -0,0 +1,44 @@
+/**
+ * @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 '../../test/common-test-setup-karma.js';
+import {FlagsServiceImplementation} from './flags_impl.js';
+
+suite('flags tests', () => {
+  let originalEnabledExperiments;
+  let flags;
+
+  suiteSetup(() => {
+    originalEnabledExperiments = window.ENABLED_EXPERIMENTS;
+    window.ENABLED_EXPERIMENTS = ['a', 'a'];
+    flags = new FlagsServiceImplementation();
+  });
+
+  suiteTeardown(() => {
+    window.ENABLED_EXPERIMENTS = originalEnabledExperiments;
+  });
+
+  test('isEnabled', () => {
+    assert.equal(flags.isEnabled('a'), true);
+    assert.equal(flags.isEnabled('random'), false);
+  });
+
+  test('enabledExperiments', () => {
+    assert.deepEqual(flags.enabledExperiments, ['a']);
+  });
+});
+
diff --git a/polygerrit-ui/app/services/flags_test.html b/polygerrit-ui/app/services/flags_test.html
deleted file mode 100644
index 51efb0d..0000000
--- a/polygerrit-ui/app/services/flags_test.html
+++ /dev/null
@@ -1,44 +0,0 @@
-<!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/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
new file mode 100644
index 0000000..f7fdadf
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
@@ -0,0 +1,68 @@
+/**
+ * @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 enum AuthType {
+  XSRF_TOKEN = 'xsrf_token',
+  ACCESS_TOKEN = 'access_token',
+}
+
+export enum AuthStatus {
+  UNDETERMINED = 0,
+  AUTHED = 1,
+  NOT_AUTHED = 2,
+  ERROR = 3,
+}
+
+export interface Token {
+  access_token?: string;
+  expires_at?: string;
+}
+
+export type GetTokenCallback = () => Promise<Token | null>;
+
+export interface DefaultAuthOptions {
+  credentials: RequestCredentials;
+}
+
+export interface AuthRequestInit extends RequestInit {
+  // RequestInit define headers as HeadersInit, i.e.
+  // Headers | string[][] | Record<string, string>
+  // Auth class supports only Headers in options
+  headers?: Headers;
+}
+
+export interface AuthService {
+  baseUrl: string;
+  isAuthed: boolean;
+
+  /**
+   * Returns if user is authed or not.
+   */
+  authCheck(): Promise<boolean>;
+
+  clearCache(): void;
+
+  /**
+   * Enable cross-domain authentication using OAuth access token.
+   */
+  setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions): void;
+
+  /**
+   * Perform network fetch with authentication.
+   */
+  fetch(url: string, opt_options?: AuthRequestInit): Promise<Response>;
+}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
new file mode 100644
index 0000000..8fe7c35
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -0,0 +1,291 @@
+/**
+ * @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 {getBaseUrl} from '../../utils/url-util';
+import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {
+  AuthRequestInit,
+  AuthService,
+  AuthStatus,
+  AuthType,
+  DefaultAuthOptions,
+  GetTokenCallback,
+  Token,
+} from './gr-auth';
+
+const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
+const MAX_GET_TOKEN_RETRIES = 2;
+
+interface ValidToken extends Token {
+  access_token: string;
+  expires_at: string;
+}
+
+interface AuthRequestInitWithHeaders extends AuthRequestInit {
+  // RequestInit define headers as optional property with a type
+  // Headers | string[][] | Record<string, string>
+  // In Auth class headers property is always set and has type Headers
+  headers: Headers;
+}
+
+/**
+ * Auth class.
+ */
+export class Auth implements AuthService {
+  // TODO(dmfilippov): Remove Type and Status properties, expose AuthType and
+  // AuthStatus to API
+  static TYPE = {
+    XSRF_TOKEN: AuthType.XSRF_TOKEN,
+    ACCESS_TOKEN: AuthType.ACCESS_TOKEN,
+  };
+
+  static STATUS = {
+    UNDETERMINED: AuthStatus.UNDETERMINED,
+    AUTHED: AuthStatus.AUTHED,
+    NOT_AUTHED: AuthStatus.NOT_AUTHED,
+    ERROR: AuthStatus.ERROR,
+  };
+
+  static CREDS_EXPIRED_MSG = 'Credentials expired.';
+
+  private _authCheckPromise?: Promise<Response>;
+
+  private _last_auth_check_time: number = Date.now();
+
+  private _status = AuthStatus.UNDETERMINED;
+
+  private _retriesLeft = MAX_GET_TOKEN_RETRIES;
+
+  private _cachedTokenPromise: Promise<Token | null> | null = null;
+
+  private _type?: AuthType;
+
+  private _defaultOptions: AuthRequestInit = {};
+
+  private _getToken: GetTokenCallback;
+
+  public eventEmitter: EventEmitterService;
+
+  constructor(eventEmitter: EventEmitterService) {
+    this._getToken = () => Promise.resolve(this._cachedTokenPromise);
+    this.eventEmitter = eventEmitter;
+  }
+
+  get baseUrl() {
+    return getBaseUrl();
+  }
+
+  /**
+   * Returns if user is authed or not.
+   */
+  authCheck(): Promise<boolean> {
+    if (
+      !this._authCheckPromise ||
+      Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
+    ) {
+      // Refetch after last check expired
+      this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this._last_auth_check_time = Date.now();
+    }
+
+    return this._authCheckPromise
+      .then(res => {
+        // auth-check will return 204 if authed
+        // treat the rest as unauthed
+        if (res.status === 204) {
+          this._setStatus(Auth.STATUS.AUTHED);
+          return true;
+        } else {
+          this._setStatus(Auth.STATUS.NOT_AUTHED);
+          return false;
+        }
+      })
+      .catch(() => {
+        this._setStatus(AuthStatus.ERROR);
+        // Reset _authCheckPromise to avoid caching the failed promise
+        this._authCheckPromise = undefined;
+        return false;
+      });
+  }
+
+  clearCache() {
+    this._authCheckPromise = undefined;
+  }
+
+  private _setStatus(status: AuthStatus) {
+    if (this._status === status) return;
+
+    if (this._status === AuthStatus.AUTHED) {
+      this.eventEmitter.emit('auth-error', {
+        message: Auth.CREDS_EXPIRED_MSG,
+        action: 'Refresh credentials',
+      });
+    }
+    this._status = status;
+  }
+
+  get status() {
+    return this._status;
+  }
+
+  get isAuthed() {
+    return this._status === Auth.STATUS.AUTHED;
+  }
+
+  /**
+   * Enable cross-domain authentication using OAuth access token.
+   */
+  setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions) {
+    this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+    if (getToken) {
+      this._type = AuthType.ACCESS_TOKEN;
+      this._cachedTokenPromise = null;
+      this._getToken = getToken;
+    }
+    this._defaultOptions = {};
+    if (defaultOptions) {
+      this._defaultOptions.credentials = defaultOptions.credentials;
+    }
+  }
+
+  /**
+   * Perform network fetch with authentication.
+   */
+  fetch(url: string, opt_options?: AuthRequestInit): Promise<Response> {
+    const options: AuthRequestInitWithHeaders = {
+      headers: new Headers(),
+      ...this._defaultOptions,
+      ...opt_options,
+    };
+    if (this._type === AuthType.ACCESS_TOKEN) {
+      return this._getAccessToken().then(accessToken =>
+        this._fetchWithAccessToken(url, options, accessToken)
+      );
+    } else {
+      return this._fetchWithXsrfToken(url, options);
+    }
+  }
+
+  private _getCookie(name: string): string {
+    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 false;
+    });
+    return result;
+  }
+
+  private _isTokenValid(token: Token | null): token is ValidToken {
+    if (!token) {
+      return false;
+    }
+    if (!token.access_token || !token.expires_at) {
+      return false;
+    }
+
+    const expiration = new Date(Number(token.expires_at) * 1000);
+    if (Date.now() >= expiration.getTime()) {
+      return false;
+    }
+
+    return true;
+  }
+
+  private _fetchWithXsrfToken(
+    url: string,
+    options: AuthRequestInitWithHeaders
+  ): Promise<Response> {
+    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);
+  }
+
+  private _getAccessToken(): Promise<string | null> {
+    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 (this._retriesLeft > 0) {
+        this._retriesLeft--;
+        this._cachedTokenPromise = null;
+        return this._getAccessToken();
+      }
+      // Fall back to anonymous access.
+      return null;
+    });
+  }
+
+  private _fetchWithAccessToken(
+    url: string,
+    options: AuthRequestInitWithHeaders,
+    accessToken: string | null
+  ): Promise<Response> {
+    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);
+      }
+    }
+
+    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';
+      }
+    }
+
+    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);
+  }
+}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
new file mode 100644
index 0000000..80938ad
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
@@ -0,0 +1,374 @@
+/**
+ * @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 '../../test/common-test-setup-karma.js';
+import {Auth} from './gr-auth_impl.js';
+import {appContext} from '../app-context.js';
+import {stubBaseUrl} from '../../test/test-utils.js';
+
+suite('gr-auth', () => {
+  let auth;
+
+  setup(() => {
+    auth = appContext.authService;
+  });
+
+  suite('Auth class methods', () => {
+    let fakeFetch;
+    setup(() => {
+      auth = new Auth(appContext.eventEmitter);
+      fakeFetch = sinon.stub(window, 'fetch');
+    });
+
+    test('auth-check returns 403', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        done();
+      });
+    });
+
+    test('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 behavior', () => {
+    let fakeFetch;
+    let clock;
+    setup(() => {
+      auth = new Auth(appContext.eventEmitter);
+      clock = sinon.useFakeTimers();
+      fakeFetch = sinon.stub(window, 'fetch');
+    });
+
+    test('cache auth-check result', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          done();
+        });
+      });
+    });
+
+    test('clearCache should refetch auth-check result', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.clearCache();
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          done();
+        });
+      });
+    });
+
+    test('cache expired on auth-check after certain time', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          done();
+        });
+      });
+    });
+
+    test('no cache if auth-check failed', done => {
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.ERROR);
+        assert.equal(fakeFetch.callCount, 1);
+        auth.authCheck().then(() => {
+          assert.equal(fakeFetch.callCount, 2);
+          done();
+        });
+      });
+    });
+
+    test('fire event when switch from authed to unauthed', done => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.authCheck().then(authed => {
+        assert.isTrue(authed);
+        assert.equal(auth.status, Auth.STATUS.AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.resolve({status: 403}));
+        const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          assert.isTrue(emitStub.called);
+          done();
+        });
+      });
+    });
+
+    test('fire event when switch from authed to error', done => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.authCheck().then(authed => {
+        assert.isTrue(authed);
+        assert.equal(auth.status, Auth.STATUS.AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.reject(new Error('random error')));
+        const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.isTrue(emitStub.called);
+          assert.equal(auth.status, Auth.STATUS.ERROR);
+          done();
+        });
+      });
+    });
+
+    test('no event from non-authed to other status', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
+          assert.isFalse(emitStub.called);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          done();
+        });
+      });
+    });
+
+    test('no event from non-authed to other status', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.reject(new Error('random error')));
+        const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+        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(() => {
+      sinon.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 => {
+      sinon.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(() => {
+      sinon.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 = sinon.stub();
+      getToken.returns(Promise.resolve(makeToken()));
+      auth.setup(getToken);
+    });
+
+    test('base url support', done => {
+      const baseUrl = 'http://foo';
+      stubBaseUrl(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 => {
+      sinon.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();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
new file mode 100644
index 0000000..d59a022
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
@@ -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.
+ */
+
+export type EventCallback = (...args: any) => void;
+export type UnsubscribeMethod = () => void;
+
+export interface EventEmitterService {
+  /**
+   * Register an event listener to an event.
+   */
+  addListener(eventName: string, cb: EventCallback): UnsubscribeMethod;
+
+  /**
+   * Alias for addListener.
+   */
+  on(eventName: string, cb: EventCallback): UnsubscribeMethod;
+
+  /**
+   * Attach event handler only once. Automatically removed.
+   */
+  once(eventName: string, cb: EventCallback): UnsubscribeMethod;
+
+  /**
+   * De-register an event listener to an event.
+   */
+  removeListener(eventName: string, cb: EventCallback): void;
+
+  /**
+   * Alias to removeListener
+   */
+  off(eventName: string, cb: EventCallback): void;
+
+  /**
+   * 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.
+   */
+  emit(eventName: string, detail: any): boolean;
+
+  /**
+   * Alias to emit.
+   */
+  dispatch(eventName: string, detail: any): boolean;
+
+  /**
+   * Remove listeners for a specific event or all.
+   *
+   * @param eventName if not provided, will remove all
+   */
+  removeAllListeners(eventName: string): void;
+}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
new file mode 100644
index 0000000..72afbda
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
@@ -0,0 +1,130 @@
+/**
+ * @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 {
+  EventCallback,
+  EventEmitterService,
+  UnsubscribeMethod,
+} from './gr-event-interface';
+/**
+ * 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 implements EventEmitterService {
+  private _listenersMap = new Map<string, EventCallback[]>();
+
+  /**
+   * Register an event listener to an event.
+   */
+  addListener(eventName: string, cb: EventCallback): UnsubscribeMethod {
+    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: string, cb: EventCallback): UnsubscribeMethod {
+    return this.addListener(eventName, cb);
+  }
+
+  /**
+   * Attach event handler only once. Automatically removed.
+   */
+  once(eventName: string, cb: EventCallback): UnsubscribeMethod {
+    const onceWrapper = (...args: any[]) => {
+      cb(...args);
+      this.off(eventName, onceWrapper);
+    };
+    return this.on(eventName, onceWrapper);
+  }
+
+  /**
+   * De-register an event listener to an event.
+   */
+  removeListener(eventName: string, cb: EventCallback): void {
+    let listeners = this._listenersMap.get(eventName) || [];
+    listeners = listeners.filter(listener => listener !== cb);
+    this._listenersMap.set(eventName, listeners);
+  }
+
+  /**
+   * Alias to removeListener
+   */
+  off(eventName: string, cb: EventCallback): void {
+    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.
+   */
+  emit(eventName: string, detail: any): boolean {
+    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: string, detail: any): boolean {
+    return this.emit(eventName, detail);
+  }
+
+  /**
+   * Remove listeners for a specific event or all.
+   *
+   * @param eventName if not provided, will remove all
+   */
+  removeAllListeners(eventName: string): void {
+    if (eventName) {
+      this._listenersMap.set(eventName, []);
+    } else {
+      this._listenersMap = new Map();
+    }
+  }
+}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
new file mode 100644
index 0000000..32590e0
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import '../../elements/shared/gr-js-api-interface/gr-js-api-interface.js';
+import {EventEmitter} from './gr-event-interface_impl.js';
+
+const basicFixture = fixtureFromElement('gr-js-api-interface');
+
+suite('gr-event-interface tests', () => {
+  let gerrit;
+  setup(() => {
+    gerrit = window.Gerrit;
+  });
+
+  suite('test on Gerrit', () => {
+    setup(() => {
+      basicFixture.instantiate();
+      gerrit.removeAllListeners();
+    });
+
+    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);
+        done();
+      });
+      gerrit.install(plugin => {
+        p = plugin;
+        gerrit.emit(eventName, {value: 'test', plugin});
+      }, '0.1',
+      'http://test.com/plugins/testplugin/static/test.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);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
new file mode 100644
index 0000000..743e0f4
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -0,0 +1,97 @@
+/**
+ * @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 type EventValue = string | number | {error?: Error};
+
+// TODO(dmfilippov): TS-fix-any use more specific type instead if possible
+export type EventDetails = any;
+
+export interface Timer {
+  reset(): this;
+  end(): this;
+  withMaximum(maximum: number): this;
+}
+
+export interface ReportingService {
+  reporter(
+    type: string,
+    category: string,
+    eventName: string,
+    eventValue?: EventValue,
+    eventDetails?: EventDetails,
+    opt_noLog?: boolean
+  ): void;
+
+  appStarted(): void;
+  onVisibilityChange(): void;
+  beforeLocationChanged(): void;
+  locationChanged(page: string): void;
+  dashboardDisplayed(): void;
+  changeDisplayed(): void;
+  changeFullyLoaded(): void;
+  diffViewDisplayed(): void;
+  diffViewFullyLoaded(): void;
+  diffViewContentDisplayed(): void;
+  fileListDisplayed(): void;
+  reportExtension(name: string): void;
+  pluginLoaded(name: string): void;
+  pluginsLoaded(pluginsList?: string[]): void;
+  /**
+   * Reset named timer.
+   */
+  time(name: string): void;
+  /**
+   * Finish named timer and report it to server.
+   */
+  timeEnd(name: string, eventDetails?: EventDetails): void;
+  /**
+   * Reports just line timeEnd, but additionally reports an average given a
+   * denominator and a separate reporiting name for the average.
+   *
+   * @param name Timing name.
+   * @param averageName Average timing name.
+   * @param denominator Number by which to divide the total to
+   *     compute the average.
+   */
+  timeEndWithAverage(
+    name: string,
+    averageName: string,
+    denominator: number
+  ): void;
+  /**
+   * 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.
+   */
+  getTimer(name: string): Timer;
+  /**
+   * Log timing information for an RPC.
+   *
+   * @param anonymizedUrl The URL of the RPC with tokens obfuscated.
+   * @param elapsed The time elapsed of the RPC.
+   */
+  reportRpcTiming(anonymizedUrl: string, elapsed: number): void;
+  reportLifeCycle(eventName: string, details?: EventDetails): void;
+  reportInteraction(eventName: string, details?: EventDetails): void;
+  /**
+   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * timer.
+   */
+  recordDraftInteraction(): void;
+  reportErrorDialog(message: string): void;
+  setRepoName(repoName: string): void;
+}
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
new file mode 100644
index 0000000..f3aacdf
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -0,0 +1,823 @@
+/**
+ * @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 {AppContext} from '../app-context';
+import {FlagsService} from '../flags/flags';
+import {
+  EventDetails,
+  EventValue,
+  ReportingService,
+  Timer,
+} from './gr-reporting';
+import {hasOwnProperty} from '../../utils/common-util';
+
+// Latency reporting constants.
+
+const TIMING = {
+  TYPE: 'timing-report',
+  CATEGORY: {
+    UI_LATENCY: 'UI Latency',
+    RPC: 'RPC Timing',
+  },
+  EVENT: {
+    APP_STARTED: 'App Started',
+  },
+};
+
+const LIFECYCLE = {
+  TYPE: 'lifecycle',
+  CATEGORY: {
+    DEFAULT: 'Default',
+    EXTENSION_DETECTED: 'Extension detected',
+    PLUGINS_INSTALLED: 'Plugins installed',
+    VISIBILITY: 'Visibility',
+  },
+};
+
+const INTERACTION = {
+  TYPE: 'interaction',
+  CATEGORY: {
+    DEFAULT: 'Default',
+    VISIBILITY: 'Visibility',
+  },
+};
+
+const NAVIGATION = {
+  TYPE: 'nav-report',
+  CATEGORY: {
+    LOCATION_CHANGED: 'Location Changed',
+  },
+  EVENT: {
+    PAGE: 'Page',
+  },
+};
+
+const ERROR = {
+  TYPE: 'error',
+  CATEGORY: {
+    EXCEPTION: 'exception',
+    ERROR_DIALOG: '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 STARTUP_TIMERS = {
+  [TIMER.PLUGINS_LOADED]: 0,
+  [TIMER.METRICS_PLUGIN_LOADED]: 0,
+  [TIMER.STARTUP_CHANGE_DISPLAYED]: 0,
+  [TIMER.STARTUP_CHANGE_LOAD_FULL]: 0,
+  [TIMER.STARTUP_DASHBOARD_DISPLAYED]: 0,
+  [TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED]: 0,
+  [TIMER.STARTUP_DIFF_VIEW_DISPLAYED]: 0,
+  [TIMER.STARTUP_DIFF_VIEW_LOAD_FULL]: 0,
+  [TIMER.STARTUP_FILE_LIST_DISPLAYED]: 0,
+  [TIMING.EVENT.APP_STARTED]: 0,
+  // WebComponentsReady timer is triggered from gr-router.
+  [TIMER.WEB_COMPONENTS_READY]: 0,
+};
+
+const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
+const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
+const SLOW_RPC_THRESHOLD = 500;
+
+export function initErrorReporter(appContext: AppContext) {
+  const reportingService = appContext.reportingService;
+  // TODO(dmfilippo): TS-fix-any oldOnError - define correct type
+  const onError = function (
+    oldOnError: Function,
+    msg: Event | string,
+    url?: string,
+    line?: number,
+    column?: number,
+    error?: 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');
+      }
+      msg = shortenedErrorStack || error.toString();
+    }
+    const payload = {
+      url,
+      line,
+      column,
+      error,
+    };
+    reportingService.reporter(
+      ERROR.TYPE,
+      ERROR.CATEGORY.EXCEPTION,
+      `${msg}`,
+      payload
+    );
+    return true;
+  };
+  // TODO(dmfilippov): TS-fix-any unclear what is context
+  const catchErrors = function (opt_context?: any) {
+    const context = opt_context || window;
+    const oldOnError = context.onerror;
+    context.onerror = (
+      event: Event | string,
+      source?: string,
+      lineno?: number,
+      colno?: number,
+      error?: Error
+    ) => {
+      return onError(oldOnError, event, source, lineno, colno, error);
+    };
+    context.addEventListener(
+      'unhandledrejection',
+      (e: PromiseRejectionEvent) => {
+        const msg = e.reason.message;
+        const payload = {
+          error: e.reason,
+        };
+        reportingService.reporter(
+          ERROR.TYPE,
+          ERROR.CATEGORY.EXCEPTION,
+          msg,
+          payload
+        );
+      }
+    );
+  };
+
+  catchErrors();
+
+  // for testing
+  return {catchErrors};
+}
+
+export function initPerformanceReporter(appContext: AppContext) {
+  const reportingService = appContext.reportingService;
+  // 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) {
+            reportingService.reporter(
+              TIMING.TYPE,
+              TIMING.CATEGORY.UI_LATENCY,
+              `Task ${task.name}`,
+              Math.round(task.duration),
+              {},
+              false
+            );
+          }
+        }
+      });
+      catchLongJsTasks.observe({entryTypes: ['longtask']});
+    }
+  }
+}
+
+export function initVisibilityReporter(appContext: AppContext) {
+  const reportingService = appContext.reportingService;
+  document.addEventListener('visibilitychange', () => {
+    reportingService.onVisibilityChange();
+  });
+}
+
+// Calculates the time of Gerrit being in a background tab. When Gerrit reports
+// a pageLoad metric it’s attached to its details for latency analysis.
+// It resets on locationChange.
+class HiddenDurationTimer {
+  public accHiddenDurationMs = 0;
+
+  public lastVisibleTimestampMs: number | null = null;
+
+  constructor() {
+    this.reset();
+  }
+
+  reset() {
+    this.accHiddenDurationMs = 0;
+    this.lastVisibleTimestampMs = 0;
+  }
+
+  onVisibilityChange() {
+    if (document.visibilityState === 'hidden') {
+      this.lastVisibleTimestampMs = now();
+    } else if (document.visibilityState === 'visible') {
+      if (this.lastVisibleTimestampMs !== null) {
+        this.accHiddenDurationMs += now() - this.lastVisibleTimestampMs;
+        // Set to null for guarding against two 'visible' events in a row.
+        this.lastVisibleTimestampMs = null;
+      }
+    }
+  }
+
+  get hiddenDurationMs() {
+    if (
+      document.visibilityState === 'hidden' &&
+      this.lastVisibleTimestampMs !== null
+    ) {
+      return this.accHiddenDurationMs + now() - this.lastVisibleTimestampMs;
+    }
+    return this.accHiddenDurationMs;
+  }
+}
+
+export function now() {
+  return Math.round(window.performance.now());
+}
+
+type PeformanceTimingEventName = keyof Omit<PerformanceTiming, 'toJSON'>;
+
+interface EventInfo {
+  type: string;
+  category: string;
+  name: string;
+  value?: EventValue;
+  eventStart: number;
+  eventDetails?: string;
+  repoName?: string;
+  inBackgroundTab?: boolean;
+  enabledExperiments?: string;
+}
+
+interface PageLoadDetails {
+  rpcList: SlowRpcCall[];
+  hiddenDurationMs: number;
+  screenSize?: {width: number; height: number};
+  viewport?: {width: number; height: number};
+  usedJSHeapSizeMb?: number;
+}
+
+interface SlowRpcCall {
+  anonymizedUrl: string;
+  elapsed: number;
+}
+
+type PendingReportInfo = [EventInfo, boolean | undefined];
+
+export class GrReporting implements ReportingService {
+  private readonly _flagsService: FlagsService;
+
+  private readonly _baselines = STARTUP_TIMERS;
+
+  private _reportRepoName: string | undefined;
+
+  private _timers: {timeBetweenDraftActions: Timer | null} = {
+    timeBetweenDraftActions: null,
+  };
+
+  private _pending: PendingReportInfo[] = [];
+
+  private _slowRpcList: SlowRpcCall[] = [];
+
+  public readonly hiddenDurationTimer = new HiddenDurationTimer();
+
+  constructor(flagsService: FlagsService) {
+    this._flagsService = flagsService;
+  }
+
+  private get performanceTiming() {
+    return window.performance.timing;
+  }
+
+  private get slowRpcSnapshot() {
+    return (this._slowRpcList || []).slice();
+  }
+
+  private _arePluginsLoaded() {
+    return (
+      this._baselines && !hasOwnProperty(this._baselines, TIMER.PLUGINS_LOADED)
+    );
+  }
+
+  private _isMetricsPluginLoaded() {
+    return (
+      this._arePluginsLoaded() ||
+      (this._baselines &&
+        !hasOwnProperty(this._baselines, TIMER.METRICS_PLUGIN_LOADED))
+    );
+  }
+
+  /**
+   * Reporter reports events. Events will be queued if metrics plugin is not
+   * yet installed.
+   *
+   * @param noLog If true, the event will not be logged to the JS console.
+   */
+  reporter(
+    type: string,
+    category: string,
+    eventName: string,
+    eventValue?: EventValue,
+    eventDetails?: EventDetails,
+    noLog?: boolean
+  ) {
+    const eventInfo = this._createEventInfo(
+      type,
+      category,
+      eventName,
+      eventValue,
+      eventDetails
+    );
+    if (type === ERROR.TYPE && category === ERROR.CATEGORY.EXCEPTION) {
+      console.error((eventValue && (eventValue as any).error) || eventName);
+    }
+
+    // We report events immediately when metrics plugin is loaded
+    if (this._isMetricsPluginLoaded() && !this._pending.length) {
+      this._reportEvent(eventInfo, noLog);
+    } else {
+      // We cache until metrics plugin is loaded
+      this._pending.push([eventInfo, noLog]);
+      if (this._isMetricsPluginLoaded()) {
+        this._pending.forEach(([eventInfo, opt_noLog]) => {
+          this._reportEvent(eventInfo, opt_noLog);
+        });
+        this._pending = [];
+      }
+    }
+  }
+
+  private _reportEvent(eventInfo: EventInfo, opt_noLog?: boolean) {
+    const {type, value, name} = eventInfo;
+    document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
+    if (opt_noLog) {
+      return;
+    }
+    if (type !== ERROR.TYPE) {
+      if (value !== undefined) {
+        console.info(`Reporting: ${name}: ${value}`);
+      } else {
+        console.info(`Reporting: ${name}`);
+      }
+    }
+  }
+
+  private _createEventInfo(
+    type: string,
+    category: string,
+    name: string,
+    value?: EventValue,
+    eventDetails?: EventDetails
+  ): EventInfo {
+    const eventInfo: EventInfo = {
+      type,
+      category,
+      name,
+      value,
+      eventStart: now(),
+    };
+
+    if (
+      typeof eventDetails === 'object' &&
+      Object.entries(eventDetails).length !== 0
+    ) {
+      eventInfo.eventDetails = JSON.stringify(eventDetails);
+    }
+
+    if (this._reportRepoName) {
+      eventInfo.repoName = this._reportRepoName;
+    }
+
+    const isInBackgroundTab = document.visibilityState === 'hidden';
+    if (isInBackgroundTab !== undefined) {
+      eventInfo.inBackgroundTab = isInBackgroundTab;
+    }
+
+    if (this._flagsService.enabledExperiments.length) {
+      eventInfo.enabledExperiments = JSON.stringify(
+        this._flagsService.enabledExperiments
+      );
+    }
+
+    return eventInfo;
+  }
+
+  /**
+   * User-perceived app start time, should be reported when the app is ready.
+   */
+  appStarted() {
+    this.timeEnd(TIMING.EVENT.APP_STARTED);
+    this._reportNavResTimes();
+  }
+
+  onVisibilityChange() {
+    this.hiddenDurationTimer.onVisibilityChange();
+    const eventName = `Visibility changed to ${document.visibilityState}`;
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.VISIBILITY,
+      eventName,
+      undefined,
+      {
+        hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
+      },
+      true
+    );
+  }
+
+  /**
+   * Browser's navigation and resource timings
+   */
+  private _reportNavResTimes() {
+    const perfEvents = Object.keys(this.performanceTiming.toJSON());
+    perfEvents.forEach(eventName =>
+      this._reportPerformanceTiming(eventName as PeformanceTimingEventName)
+    );
+  }
+
+  private _reportPerformanceTiming(
+    eventName: PeformanceTimingEventName,
+    eventDetails?: 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);
+    this._reportRepoName = undefined;
+    // reset slow rpc list since here start page loads which report these rpcs
+    this._slowRpcList = [];
+    this.hiddenDurationTimer.reset();
+  }
+
+  locationChanged(page: string) {
+    this.reporter(
+      NAVIGATION.TYPE,
+      NAVIGATION.CATEGORY.LOCATION_CHANGED,
+      NAVIGATION.EVENT.PAGE,
+      page
+    );
+  }
+
+  dashboardDisplayed() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, this._pageLoadDetails());
+    } else {
+      this.timeEnd(TIMER.DASHBOARD_DISPLAYED, this._pageLoadDetails());
+    }
+  }
+
+  changeDisplayed() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_CHANGE_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
+    } else {
+      this.timeEnd(TIMER.CHANGE_DISPLAYED, this._pageLoadDetails());
+    }
+  }
+
+  changeFullyLoaded() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_CHANGE_LOAD_FULL)) {
+      this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
+    } else {
+      this.timeEnd(TIMER.CHANGE_LOAD_FULL);
+    }
+  }
+
+  diffViewDisplayed() {
+    if (hasOwnProperty(this._baselines, 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 (hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
+    }
+  }
+
+  diffViewContentDisplayed() {
+    if (
+      hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)
+    ) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+    }
+  }
+
+  fileListDisplayed() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
+    } else {
+      this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
+    }
+  }
+
+  private _pageLoadDetails(): PageLoadDetails {
+    const details: PageLoadDetails = {
+      rpcList: this.slowRpcSnapshot,
+      hiddenDurationMs: this.hiddenDurationTimer.accHiddenDurationMs,
+    };
+
+    if (window.screen) {
+      details.screenSize = {
+        width: window.screen.width,
+        height: window.screen.height,
+      };
+    }
+
+    if (document?.documentElement) {
+      details.viewport = {
+        width: document.documentElement.clientWidth,
+        height: document.documentElement.clientHeight,
+      };
+    }
+
+    if (window.performance?.memory) {
+      const toMb = (bytes: number) =>
+        Math.round((bytes / (1024 * 1024)) * 100) / 100;
+      details.usedJSHeapSizeMb = toMb(window.performance.memory.usedJSHeapSize);
+    }
+
+    details.hiddenDurationMs = this.hiddenDurationTimer.hiddenDurationMs;
+    return details;
+  }
+
+  reportExtension(name: string) {
+    this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.EXTENSION_DETECTED, name);
+  }
+
+  pluginLoaded(name: string) {
+    if (name.startsWith('metrics-')) {
+      this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
+    }
+  }
+
+  pluginsLoaded(pluginsList?: string[]) {
+    this.timeEnd(TIMER.PLUGINS_LOADED);
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
+      LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
+      undefined,
+      {pluginsList: pluginsList || []},
+      true
+    );
+  }
+
+  /**
+   * Reset named timer.
+   */
+  time(name: string) {
+    this._baselines[name] = now();
+    window.performance.mark(`${name}-start`);
+  }
+
+  /**
+   * Finish named timer and report it to server.
+   */
+  timeEnd(name: string, eventDetails?: EventDetails) {
+    if (!hasOwnProperty(this._baselines, name)) {
+      return;
+    }
+    const baseTime = this._baselines[name];
+    delete this._baselines[name];
+    this._reportTiming(name, 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 name Timing name.
+   * @param averageName Average timing name.
+   * @param denominator Number by which to divide the total to
+   *     compute the average.
+   */
+  timeEndWithAverage(name: string, averageName: string, denominator: number) {
+    if (!hasOwnProperty(this._baselines, name)) {
+      return;
+    }
+    const baseTime = this._baselines[name];
+    this.timeEnd(name);
+
+    // Guard against division by zero.
+    if (!denominator) {
+      return;
+    }
+    const time = now() - baseTime;
+    this._reportTiming(averageName, time / denominator);
+  }
+
+  /**
+   * Send a timing report with an arbitrary time value.
+   *
+   * @param name Timing name.
+   * @param time The time to report as an integer of milliseconds.
+   * @param eventDetails non sensitive details
+   */
+  private _reportTiming(
+    name: string,
+    time: number,
+    eventDetails?: 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.
+   */
+  getTimer(name: string): Timer {
+    let called = false;
+    let start: number;
+    let max: number | null = null;
+
+    const timer: Timer = {
+      // Clear the timer and reset the start time.
+      reset: () => {
+        called = false;
+        start = 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 = 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 anonymizedUrl The URL of the RPC with tokens obfuscated.
+   * @param elapsed The time elapsed of the RPC.
+   */
+  reportRpcTiming(anonymizedUrl: string, elapsed: number) {
+    this.reporter(
+      TIMING.TYPE,
+      TIMING.CATEGORY.RPC,
+      'RPC-' + anonymizedUrl,
+      elapsed,
+      {},
+      true
+    );
+    if (elapsed >= SLOW_RPC_THRESHOLD) {
+      this._slowRpcList.push({anonymizedUrl, elapsed});
+    }
+  }
+
+  reportLifeCycle(eventName: string, details: EventDetails) {
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.DEFAULT,
+      eventName,
+      undefined,
+      details,
+      true
+    );
+  }
+
+  reportInteraction(eventName: string, details: EventDetails) {
+    this.reporter(
+      INTERACTION.TYPE,
+      INTERACTION.CATEGORY.DEFAULT,
+      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: string) {
+    this.reporter(
+      ERROR.TYPE,
+      ERROR.CATEGORY.ERROR_DIALOG,
+      'ErrorDialog: ' + message,
+      {error: new Error(message)}
+    );
+  }
+
+  setRepoName(repoName: string) {
+    this._reportRepoName = repoName;
+  }
+}
+
+export const DEFAULT_STARTUP_TIMERS = {...STARTUP_TIMERS};
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
new file mode 100644
index 0000000..924ddd9
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -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 {ReportingService, Timer} from './gr-reporting';
+
+export class MockTimer implements Timer {
+  end(): this {
+    return this;
+  }
+
+  reset(): this {
+    return this;
+  }
+
+  withMaximum(_: number): this {
+    return this;
+  }
+}
+
+export const grReportingMock: ReportingService = {
+  appStarted: () => {},
+  beforeLocationChanged: () => {},
+  changeDisplayed: () => {},
+  changeFullyLoaded: () => {},
+  dashboardDisplayed: () => {},
+  diffViewContentDisplayed: () => {},
+  diffViewDisplayed: () => {},
+  diffViewFullyLoaded: () => {},
+  fileListDisplayed: () => {},
+  getTimer: () => {
+    return new MockTimer();
+  },
+  locationChanged: () => {},
+  onVisibilityChange: () => {},
+  pluginLoaded: () => {},
+  pluginsLoaded: () => {},
+  recordDraftInteraction: () => {},
+  reporter: () => {},
+  reportErrorDialog: () => {},
+  reportExtension: () => {},
+  reportInteraction: () => {},
+  reportLifeCycle: () => {},
+  reportRpcTiming: () => {},
+  setRepoName: () => {},
+  time: () => {},
+  timeEnd: () => {},
+  timeEndWithAverage: () => {},
+};
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
new file mode 100644
index 0000000..73f8580
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.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.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {GrReporting} from './gr-reporting_impl.js';
+import {grReportingMock} from './gr-reporting_mock.js';
+suite('gr-reporting_mock tests', () => {
+  test('mocks all public methods', () => {
+    const methods = Object.getOwnPropertyNames(GrReporting.prototype)
+        .filter(name => typeof GrReporting.prototype[name] === 'function')
+        .filter(name => !name.startsWith('_') && name !== 'constructor')
+        .sort();
+    const mockMethods = Object.getOwnPropertyNames(grReportingMock)
+        .sort();
+    assert.deepEqual(methods, mockMethods);
+  });
+});
+
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
new file mode 100644
index 0000000..08e4a55
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -0,0 +1,499 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {GrReporting, DEFAULT_STARTUP_TIMERS, initErrorReporter} from './gr-reporting_impl.js';
+import {appContext} from '../app-context.js';
+suite('gr-reporting tests', () => {
+  let service;
+
+  let clock;
+  let fakePerformance;
+
+  const NOW_TIME = 100;
+
+  setup(() => {
+    clock = sinon.useFakeTimers(NOW_TIME);
+    service = new GrReporting(appContext.flagsService);
+    service._baselines = {...DEFAULT_STARTUP_TIMERS};
+    sinon.stub(service, 'reporter');
+  });
+
+  teardown(() => {
+    clock.restore();
+  });
+
+  test('appStarted', () => {
+    fakePerformance = {
+      navigationStart: 1,
+      loadEventEnd: 2,
+    };
+    fakePerformance.toJSON = () => fakePerformance;
+    sinon.stub(service, 'performanceTiming').get(() => fakePerformance);
+    sinon.stub(window.performance, 'now').returns(42);
+    service.appStarted();
+    assert.isTrue(
+        service.reporter.calledWithMatch(
+            'timing-report', 'UI Latency', 'App Started', 42
+        ));
+    assert.isTrue(
+        service.reporter.calledWithExactly(
+            'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
+            fakePerformance.loadEventEnd - fakePerformance.navigationStart,
+            undefined, true)
+    );
+  });
+
+  test('WebComponentsReady', () => {
+    sinon.stub(window.performance, 'now').returns(42);
+    service.timeEnd('WebComponentsReady');
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'WebComponentsReady', 42
+    ));
+  });
+
+  test('beforeLocationChanged', () => {
+    service._baselines['garbage'] = 'monster';
+    sinon.stub(service, 'time');
+    service.beforeLocationChanged();
+    assert.isTrue(service.time.calledWithExactly('DashboardDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('ChangeDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(service.time.calledWithExactly('DiffViewDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('FileListDisplayed'));
+    assert.isFalse(service._baselines.hasOwnProperty('garbage'));
+  });
+
+  test('changeDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.changeDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('ChangeDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupChangeDisplayed'));
+    service.changeDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('ChangeDisplayed'));
+  });
+
+  test('changeFullyLoaded', () => {
+    sinon.spy(service, 'timeEnd');
+    service.changeFullyLoaded();
+    assert.isFalse(
+        service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(
+        service.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
+    service.changeFullyLoaded();
+    assert.isTrue(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+  });
+
+  test('diffViewDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.diffViewDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('DiffViewDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupDiffViewDisplayed'));
+    service.diffViewDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('DiffViewDisplayed'));
+  });
+
+  test('fileListDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.fileListDisplayed();
+    assert.isFalse(
+        service.timeEnd.calledWithExactly('FileListDisplayed'));
+    assert.isTrue(
+        service.timeEnd.calledWithExactly('StartupFileListDisplayed'));
+    service.fileListDisplayed();
+    assert.isTrue(service.timeEnd.calledWithExactly('FileListDisplayed'));
+  });
+
+  test('dashboardDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.dashboardDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('DashboardDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupDashboardDisplayed'));
+    service.dashboardDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('DashboardDisplayed'));
+  });
+
+  test('dashboardDisplayed details', () => {
+    sinon.spy(service, 'timeEnd');
+    sinon.stub(window, 'performance').value( {
+      memory: {
+        usedJSHeapSize: 1024 * 1024,
+      },
+      measure: () => {},
+      now: () => { 42; },
+    });
+    service.reportRpcTiming('/changes/*~*/comments', 500);
+    service.dashboardDisplayed();
+    assert.isTrue(
+        service.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,
+            hiddenDurationMs: 0,
+            }
+        ));
+  });
+
+  suite('hidden duration', () => {
+    let nowStub;
+    let visibilityStateStub;
+    const assertHiddenDurationsMs = hiddenDurationMs => {
+      service.dashboardDisplayed();
+      assert.isTrue(
+          service.timeEnd.calledWithMatch('StartupDashboardDisplayed',
+              {hiddenDurationMs}
+          ));
+    };
+
+    setup(() => {
+      sinon.spy(service, 'timeEnd');
+      nowStub = sinon.stub(window.performance, 'now');
+      visibilityStateStub = {
+        value: value => {
+          Object.defineProperty(document, 'visibilityState',
+              {value, configurable: true});
+        },
+      };
+    });
+
+    test('starts in hidden', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(15);
+      visibilityStateStub.value('visible');
+      service.onVisibilityChange();
+      assertHiddenDurationsMs(5);
+    });
+
+    test('full in hidden', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      assertHiddenDurationsMs(10);
+    });
+
+    test('full in visible', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('visible');
+      assertHiddenDurationsMs(0);
+    });
+
+    test('accumulated', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(15);
+      visibilityStateStub.value('visible');
+      service.onVisibilityChange();
+      nowStub.returns(20);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(25);
+      assertHiddenDurationsMs(10);
+    });
+
+    test('reset after location change', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      assertHiddenDurationsMs(10);
+      visibilityStateStub.value('visible');
+      nowStub.returns(15);
+      service.beforeLocationChanged();
+      service.timeEnd.resetHistory();
+      service.dashboardDisplayed();
+      assert.isTrue(
+          service.timeEnd.calledWithMatch('DashboardDisplayed',
+              {hiddenDurationMs: 0}
+          ));
+    });
+  });
+
+  test('time and timeEnd', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(0);
+    service.time('foo');
+    nowStub.returns(1);
+    service.time('bar');
+    nowStub.returns(2);
+    service.timeEnd('bar');
+    nowStub.returns(3);
+    service.timeEnd('foo');
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo', 3
+    ));
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'bar', 1
+    ));
+  });
+
+  test('timer object', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timer = service.getTimer('foo-bar');
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo-bar', 50));
+  });
+
+  test('timer object double call', () => {
+    const timer = service.getTimer('foo-bar');
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+    assert.throws(() => {
+      timer.end();
+    }, 'Timer for "foo-bar" already ended.');
+  });
+
+  test('timer object maximum', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timer = service.getTimer('foo-bar').withMaximum(100);
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+
+    timer.reset();
+    nowStub.returns(260);
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+  });
+
+  test('recordDraftInteraction', () => {
+    const key = 'TimeBetweenDraftActions';
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timingStub = sinon.stub(service, '_reportTiming');
+    service.recordDraftInteraction();
+    assert.isFalse(timingStub.called);
+
+    nowStub.returns(200);
+    service.recordDraftInteraction();
+    assert.isTrue(timingStub.calledOnce);
+    assert.equal(timingStub.lastCall.args[0], key);
+    assert.equal(timingStub.lastCall.args[1], 100);
+
+    nowStub.returns(350);
+    service.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);
+    service.recordDraftInteraction();
+    assert.isFalse(timingStub.calledThrice);
+  });
+
+  test('timeEndWithAverage', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(0);
+    nowStub.returns(1000);
+    service.time('foo');
+    nowStub.returns(1100);
+    service.timeEndWithAverage('foo', 'bar', 10);
+    assert.isTrue(service.reporter.calledTwice);
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo', 100));
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'bar', 10));
+  });
+
+  test('reportExtension', () => {
+    service.reportExtension('foo');
+    assert.isTrue(service.reporter.calledWithExactly(
+        'lifecycle', 'Extension detected', 'foo'
+    ));
+  });
+
+  test('reportInteraction', () => {
+    service.reporter.restore();
+    sinon.spy(service, '_reportEvent');
+    service.pluginsLoaded(); // so we don't cache
+    service.reportInteraction('button-click', {name: 'sendReply'});
+    assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
+        {
+          type: 'interaction',
+          name: 'button-click',
+          eventDetails: JSON.stringify({name: 'sendReply'}),
+        }
+    ));
+  });
+
+  test('report start time', () => {
+    service.reporter.restore();
+    sinon.stub(window.performance, 'now').returns(42);
+    sinon.spy(service, '_reportEvent');
+    const dispatchStub = sinon.spy(document, 'dispatchEvent');
+    service.pluginsLoaded();
+    service.time('timeAction');
+    service.timeEnd('timeAction');
+    assert.isTrue(service._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(() => {
+      service.reporter.restore();
+      sinon.stub(service, '_reportEvent');
+    });
+
+    test('pluginsLoaded reports time', () => {
+      sinon.stub(window.performance, 'now').returns(42);
+      service.pluginsLoaded();
+      assert.isTrue(service._reportEvent.calledWithMatch(
+          {
+            type: 'timing-report',
+            category: 'UI Latency',
+            name: 'PluginsLoaded',
+            value: 42,
+          }
+      ));
+    });
+
+    test('pluginsLoaded reports plugins', () => {
+      service.pluginsLoaded(['foo', 'bar']);
+      assert.isTrue(service._reportEvent.calledWithMatch(
+          {
+            type: 'lifecycle',
+            category: 'Plugins installed',
+            eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
+          }
+      ));
+    });
+
+    test('caches reports if plugins are not loaded', () => {
+      service.timeEnd('foo');
+      assert.isFalse(service._reportEvent.called);
+    });
+
+    test('reports if plugins are loaded', () => {
+      service.pluginsLoaded();
+      assert.isTrue(service._reportEvent.called);
+    });
+
+    test('reports if metrics plugin xyz is loaded', () => {
+      service.pluginLoaded('metrics-xyz');
+      assert.isTrue(service._reportEvent.called);
+    });
+
+    test('reports cached events preserving order', () => {
+      service.time('foo');
+      service.time('bar');
+      service.timeEnd('foo');
+      service.pluginsLoaded();
+      service.timeEnd('bar');
+      assert.isTrue(service._reportEvent.getCall(0).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency', name: 'foo'}
+      ));
+      assert.isTrue(service._reportEvent.getCall(1).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency',
+            name: 'PluginsLoaded'}
+      ));
+      assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
+          {type: 'lifecycle', category: 'Plugins installed'}
+      ));
+      assert.isTrue(service._reportEvent.getCall(3).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency', name: 'bar'}
+      ));
+    });
+  });
+
+  test('search', () => {
+    service.locationChanged('_handleSomeRoute');
+    assert.isTrue(service.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 = service.reporter;
+      fakeWindow = {
+        handlers: {},
+        addEventListener(type, handler) {
+          this.handlers[type] = handler;
+        },
+      };
+      sinon.stub(console, 'error');
+      Object.defineProperty(appContext, 'reportingService', {
+        get() {
+          return service;
+        },
+      });
+      const errorReporter = initErrorReporter(appContext);
+      errorReporter.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'));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
new file mode 100644
index 0000000..6d65d4e
--- /dev/null
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -0,0 +1,869 @@
+/**
+ * @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 {
+  AccountDetailInfo,
+  AccountExternalIdInfo,
+  AccountInfo,
+  NumericChangeId,
+  ServerInfo,
+  ProjectInfo,
+  AccountCapabilityInfo,
+  SuggestedReviewerInfo,
+  GroupNameToGroupInfoMap,
+  ParsedJSON,
+  PatchSetNum,
+  RequestPayload,
+  PreferencesInput,
+  DiffPreferencesInfo,
+  EditPreferencesInfo,
+  DiffPreferenceInput,
+  SshKeyInfo,
+  RepoName,
+  BranchName,
+  BranchInput,
+  TagInput,
+  GpgKeysInput,
+  GpgKeyId,
+  GpgKeyInfo,
+  PreferencesInfo,
+  EmailInfo,
+  ProjectAccessInfo,
+  CapabilityInfoMap,
+  ProjectAccessInput,
+  ChangeInfo,
+  ProjectInfoWithName,
+  GroupId,
+  GroupInfo,
+  GroupOptionsInput,
+  BranchInfo,
+  ConfigInfo,
+  ReviewInput,
+  EditInfo,
+  ChangeId,
+  DashboardInfo,
+  ProjectAccessInfoMap,
+  IncludedInInfo,
+  RobotCommentInfo,
+  CommentInfo,
+  PathToCommentsInfoMap,
+  PathToRobotCommentsInfoMap,
+  CommentInput,
+  GroupInput,
+  PluginInfo,
+  DocResult,
+  ContributorAgreementInfo,
+  ContributorAgreementInput,
+  Password,
+  ProjectWatchInfo,
+  NameToProjectInfoMap,
+  ProjectInput,
+  AccountId,
+  ChangeMessageId,
+  GroupAuditEventInfo,
+  EncodedGroupId,
+  Base64FileContent,
+  UrlEncodedCommentId,
+  TagInfo,
+  GitRef,
+  ConfigInput,
+  RelatedChangesInfo,
+  SubmittedTogetherInfo,
+  EmailAddress,
+  FixId,
+  FilePathToDiffInfoMap,
+  DiffInfo,
+  BlameInfo,
+  PatchRange,
+  ImagesForDiff,
+  ActionNameToActionInfoMap,
+  RevisionId,
+  GroupName,
+  DashboardId,
+  HashtagsInput,
+  Hashtag,
+  FileNameToFileInfoMap,
+  TopMenuEntryInfo,
+  MergeableInfo,
+  CommitInfo,
+} from '../../../types/common';
+import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
+
+export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+export type CancelConditionCallback = () => boolean;
+
+// TODO(TS): remove when GrReplyDialog converted to typescript
+export interface GrReplyDialog {
+  getLabelValue(label: string): string;
+  setLabelValue(label: string, value: string): void;
+  send(includeComments?: boolean, startReview?: boolean): Promise<unknown>;
+  setPluginMessage(message: string): void;
+}
+
+// Copied from gr-change-actions.js
+export enum ActionType {
+  CHANGE = 'change',
+  REVISION = 'revision',
+}
+
+// Copied from gr-change-actions.js
+export enum ActionPriority {
+  CHANGE = 2,
+  DEFAULT = 0,
+  PRIMARY = 3,
+  REVIEW = -3,
+  REVISION = 1,
+}
+
+export interface GetDiffCommentsOutput {
+  baseComments: CommentInfo[];
+  comments: CommentInfo[];
+}
+
+export interface GetDiffRobotCommentsOutput {
+  baseComments: RobotCommentInfo[];
+  comments: RobotCommentInfo[];
+}
+
+export interface RestApiService {
+  // TODO(TS): unclear what is a second parameter. Looks like it is a mistake
+  // and it must be removed
+  dispatchEvent(event: Event, detail?: unknown): boolean;
+  getConfig(noCache?: boolean): Promise<ServerInfo | undefined>;
+  getLoggedIn(): Promise<boolean>;
+  getPreferences(): Promise<PreferencesInfo | undefined>;
+  getVersion(): Promise<string | undefined>;
+  getAccount(): Promise<AccountDetailInfo | undefined>;
+  getAccountCapabilities(
+    params?: string[]
+  ): Promise<AccountCapabilityInfo | undefined>;
+  getExternalIds(): Promise<AccountExternalIdInfo[] | undefined>;
+  deleteAccountIdentity(id: string[]): Promise<unknown>;
+  getRepos(
+    filter: string | undefined,
+    reposPerPage: number,
+    offset?: number
+  ): Promise<ProjectInfoWithName[] | undefined>;
+
+  send(
+    method: HttpMethod,
+    url: string,
+    body?: RequestPayload,
+    errFn?: null | undefined,
+    contentType?: string,
+    headers?: Record<string, string>
+  ): Promise<Response>;
+
+  send(
+    method: HttpMethod,
+    url: string,
+    body?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string,
+    headers?: Record<string, string>
+  ): Promise<Response | void>;
+
+  getResponseObject(response: Response): Promise<ParsedJSON>;
+
+  getChangeSuggestedReviewers(
+    changeNum: NumericChangeId,
+    input: string,
+    errFn?: ErrorCallback
+  ): Promise<SuggestedReviewerInfo[] | undefined>;
+  getChangeSuggestedCCs(
+    changeNum: NumericChangeId,
+    input: string,
+    errFn?: ErrorCallback
+  ): Promise<SuggestedReviewerInfo[] | undefined>;
+  getSuggestedAccounts(
+    input: string,
+    n?: number,
+    errFn?: ErrorCallback
+  ): Promise<AccountInfo[] | undefined>;
+  getSuggestedGroups(
+    input: string,
+    project?: RepoName,
+    n?: number,
+    errFn?: ErrorCallback
+  ): Promise<GroupNameToGroupInfoMap | undefined>;
+  executeChangeAction(
+    changeNum: NumericChangeId,
+    method: HttpMethod | undefined,
+    endpoint: string,
+    patchNum?: PatchSetNum,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined>;
+  getRepoBranches(
+    filter: string,
+    repo: RepoName,
+    reposBranchesPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<BranchInfo[] | undefined>;
+
+  getChangeDetail(
+    changeNum: number | string,
+    opt_errFn?: ErrorCallback,
+    opt_cancelCondition?: Function
+  ): Promise<ParsedChangeInfo | null | undefined>;
+
+  getChange(
+    changeNum: ChangeId | NumericChangeId,
+    errFn: ErrorCallback
+  ): Promise<ChangeInfo | null>;
+
+  savePreferences(prefs: PreferencesInput): Promise<Response>;
+
+  getDiffPreferences(): Promise<DiffPreferencesInfo | undefined>;
+
+  saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response>;
+  saveDiffPreferences(
+    prefs: DiffPreferenceInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+  saveDiffPreferences(
+    prefs: DiffPreferenceInput,
+    errFn?: ErrorCallback
+  ): Promise<Response>;
+
+  getEditPreferences(): Promise<EditPreferencesInfo | undefined>;
+
+  saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>;
+  saveEditPreferences(
+    prefs: EditPreferencesInfo,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+  saveEditPreferences(
+    prefs: EditPreferencesInfo,
+    errFn?: ErrorCallback
+  ): Promise<Response>;
+
+  getAccountEmails(): Promise<EmailInfo[] | undefined>;
+  deleteAccountEmail(email: string): Promise<Response>;
+  setPreferredAccountEmail(email: string, errFn?: ErrorCallback): Promise<void>;
+
+  getAccountSSHKeys(): Promise<SshKeyInfo[] | undefined>;
+  deleteAccountSSHKey(key: string): void;
+  addAccountSSHKey(key: string): Promise<SshKeyInfo>;
+
+  createRepoBranch(
+    name: RepoName,
+    branch: BranchName,
+    revision: BranchInput
+  ): Promise<Response>;
+
+  createRepoBranch(
+    name: RepoName,
+    branch: BranchName,
+    revision: BranchInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  createRepoTag(
+    name: RepoName,
+    tag: string,
+    revision: TagInput
+  ): Promise<Response>;
+
+  createRepoTag(
+    name: RepoName,
+    tag: string,
+    revision: TagInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+  addAccountGPGKey(key: GpgKeysInput): Promise<Record<string, GpgKeyInfo>>;
+  deleteAccountGPGKey(id: GpgKeyId): Promise<Response>;
+  getAccountGPGKeys(): Promise<Record<string, GpgKeyInfo>>;
+  probePath(path: string): Promise<boolean>;
+
+  saveFileUploadChangeEdit(
+    changeNum: NumericChangeId,
+    path: string,
+    content: string
+  ): Promise<Response | undefined>;
+
+  deleteFileInChangeEdit(
+    changeNum: NumericChangeId,
+    path: string
+  ): Promise<Response | undefined>;
+
+  restoreFileInChangeEdit(
+    changeNum: NumericChangeId,
+    restore_path: string
+  ): Promise<Response | undefined>;
+
+  renameFileInChangeEdit(
+    changeNum: NumericChangeId,
+    old_path: string,
+    new_path: string
+  ): Promise<Response | undefined>;
+
+  queryChangeFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    query: string
+  ): Promise<string[] | undefined>;
+
+  getRepoAccessRights(
+    repoName: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<ProjectAccessInfo | undefined>;
+
+  createRepo(config: ProjectInput & {name: RepoName}): Promise<Response>;
+  createRepo(
+    config: ProjectInput & {name: RepoName},
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+  createRepo(config: ProjectInput, errFn?: ErrorCallback): Promise<Response>;
+
+  getRepo(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<ProjectInfo | undefined>;
+
+  getRepoDashboards(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<DashboardInfo[] | undefined>;
+
+  getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined>;
+
+  getProjectConfig(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<ConfigInfo | undefined>;
+
+  getCapabilities(
+    errFn?: ErrorCallback
+  ): Promise<CapabilityInfoMap | undefined>;
+
+  setRepoAccessRights(
+    repoName: RepoName,
+    repoInfo: ProjectAccessInput
+  ): Promise<Response>;
+
+  setRepoAccessRightsForReview(
+    projectName: RepoName,
+    projectInfo: ProjectAccessInput
+  ): Promise<ChangeInfo>;
+
+  getGroups(
+    filter: string,
+    groupsPerPage: number,
+    offset?: number
+  ): Promise<GroupNameToGroupInfoMap | undefined>;
+
+  getGroupConfig(
+    group: GroupId | GroupName,
+    errFn?: ErrorCallback
+  ): Promise<GroupInfo | undefined>;
+
+  getIsAdmin(): Promise<boolean | undefined>;
+
+  getIsGroupOwner(groupName: GroupName): Promise<boolean>;
+
+  saveGroupName(
+    groupId: GroupId | GroupName,
+    name: GroupName
+  ): Promise<Response>;
+
+  saveGroupOwner(
+    groupId: GroupId | GroupName,
+    ownerId: string
+  ): Promise<Response>;
+
+  saveGroupDescription(
+    groupId: GroupId,
+    description: string
+  ): Promise<Response>;
+
+  saveGroupOptions(
+    groupId: GroupId,
+    options: GroupOptionsInput
+  ): Promise<Response>;
+
+  saveChangeReview(
+    changeNum: ChangeId | NumericChangeId,
+    patchNum: RevisionId,
+    review: ReviewInput
+  ): Promise<Response>;
+  saveChangeReview(
+    changeNum: ChangeId | NumericChangeId,
+    patchNum: RevisionId,
+    review: ReviewInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+  saveChangeReview(
+    changeNum: ChangeId | NumericChangeId,
+    patchNum: RevisionId,
+    review: ReviewInput,
+    errFn?: ErrorCallback
+  ): Promise<Response>;
+
+  getChangeEdit(
+    changeNum: NumericChangeId,
+    downloadCommands?: boolean
+  ): Promise<false | EditInfo | undefined>;
+
+  getChangeActionURL(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum | undefined,
+    endpoint: string
+  ): Promise<string>;
+
+  createChange(
+    project: RepoName,
+    branch: BranchName,
+    subject: string,
+    topic?: string,
+    isPrivate?: boolean,
+    workInProgress?: boolean,
+    baseChange?: ChangeId,
+    baseCommit?: string
+  ): Promise<ChangeInfo | undefined>;
+
+  getChangeIncludedIn(
+    changeNum: NumericChangeId
+  ): Promise<IncludedInInfo | undefined>;
+
+  getFromProjectLookup(
+    changeNum: NumericChangeId
+  ): Promise<RepoName | undefined>;
+
+  saveDiffDraft(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: CommentInput
+  ): Promise<Response>;
+
+  getDiffChangeDetail(
+    changeNum: NumericChangeId,
+    errFn?: ErrorCallback,
+    cancelCondition?: CancelConditionCallback
+  ): Promise<ChangeInfo | undefined | null>;
+
+  getDiffComments(
+    changeNum: NumericChangeId
+  ): Promise<PathToCommentsInfoMap | undefined>;
+  getDiffComments(
+    changeNum: NumericChangeId,
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    path: string
+  ): Promise<GetDiffCommentsOutput>;
+  getDiffComments(
+    changeNum: NumericChangeId,
+    basePatchNum?: PatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ):
+    | Promise<PathToCommentsInfoMap | undefined>
+    | Promise<GetDiffCommentsOutput>;
+
+  getDiffRobotComments(
+    changeNum: NumericChangeId
+  ): Promise<PathToRobotCommentsInfoMap | undefined>;
+  getDiffRobotComments(
+    changeNum: NumericChangeId,
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    path: string
+  ): Promise<GetDiffRobotCommentsOutput>;
+  getDiffRobotComments(
+    changeNum: NumericChangeId,
+    basePatchNum?: PatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ):
+    | Promise<GetDiffRobotCommentsOutput>
+    | Promise<PathToRobotCommentsInfoMap | undefined>;
+
+  getDiffDrafts(
+    changeNum: NumericChangeId
+  ): Promise<PathToCommentsInfoMap | undefined>;
+  getDiffDrafts(
+    changeNum: NumericChangeId,
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    path: string
+  ): Promise<GetDiffCommentsOutput>;
+  getDiffDrafts(
+    changeNum: NumericChangeId,
+    basePatchNum?: PatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ):
+    | Promise<GetDiffCommentsOutput>
+    | Promise<PathToCommentsInfoMap | undefined>;
+
+  createGroup(config: GroupInput & {name: string}): Promise<Response>;
+  createGroup(
+    config: GroupInput & {name: string},
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+  createGroup(config: GroupInput, errFn?: ErrorCallback): Promise<Response>;
+
+  getPlugins(
+    filter: string,
+    pluginsPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<{[pluginName: string]: PluginInfo} | undefined>;
+
+  getChanges(
+    changesPerPage?: number,
+    query?: string,
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[] | undefined>;
+  getChanges(
+    changesPerPage?: number,
+    query?: string[],
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[][] | undefined>;
+  /**
+   * @return 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(
+    changesPerPage?: number,
+    query?: string | string[],
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined>;
+
+  getDocumentationSearches(filter: string): Promise<DocResult[] | undefined>;
+
+  getAccountAgreements(): Promise<ContributorAgreementInfo[] | undefined>;
+
+  getAccountGroups(): Promise<GroupInfo[] | undefined>;
+
+  getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined>;
+
+  getAccountStatus(userId: AccountId): Promise<string | undefined>;
+
+  saveAccountAgreement(name: ContributorAgreementInput): Promise<Response>;
+
+  generateAccountHttpPassword(): Promise<Password>;
+
+  setAccountName(name: string, errFn?: ErrorCallback): Promise<void>;
+
+  setAccountUsername(username: string, errFn?: ErrorCallback): Promise<void>;
+
+  getWatchedProjects(): Promise<ProjectWatchInfo[] | undefined>;
+
+  saveWatchedProjects(
+    projects: ProjectWatchInfo[],
+    errFn?: ErrorCallback
+  ): Promise<ProjectWatchInfo[]>;
+
+  deleteWatchedProjects(
+    projects: ProjectWatchInfo[]
+  ): Promise<Response | undefined>;
+  deleteWatchedProjects(
+    projects: ProjectWatchInfo[],
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+  deleteWatchedProjects(
+    projects: ProjectWatchInfo[],
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  getSuggestedProjects(
+    inputVal: string,
+    n?: number,
+    errFn?: ErrorCallback
+  ): Promise<NameToProjectInfoMap | undefined>;
+
+  invalidateGroupsCache(): void;
+  invalidateReposCache(): void;
+  invalidateAccountsCache(): void;
+  invalidateAccountsDetailCache(): void;
+  removeFromAttentionSet(
+    changeNum: NumericChangeId,
+    user: AccountId,
+    reason: string
+  ): Promise<Response>;
+  addToAttentionSet(
+    changeNum: NumericChangeId,
+    user: AccountId | undefined | null,
+    reason: string
+  ): Promise<Response>;
+  setAccountDisplayName(
+    displayName: string,
+    errFn?: ErrorCallback
+  ): Promise<void>;
+  setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void>;
+  getAvatarChangeUrl(): Promise<string | undefined>;
+  setDescription(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    desc: string
+  ): Promise<Response>;
+  deleteVote(
+    changeNum: NumericChangeId,
+    account: AccountId,
+    label: string
+  ): Promise<Response>;
+
+  deleteComment(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    commentID: UrlEncodedCommentId,
+    reason: string
+  ): Promise<CommentInfo>;
+  deleteDiffDraft(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: {id: UrlEncodedCommentId}
+  ): Promise<Response>;
+
+  deleteChangeCommitMessage(
+    changeNum: NumericChangeId,
+    messageId: ChangeMessageId
+  ): Promise<Response>;
+
+  removeChangeReviewer(
+    changeNum: NumericChangeId,
+    reviewerID: AccountId | EmailAddress | GroupId
+  ): Promise<Response | undefined>;
+
+  getGroupAuditLog(
+    group: EncodedGroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupAuditEventInfo[] | undefined>;
+
+  getGroupMembers(
+    groupName: GroupId | GroupName,
+    errFn?: ErrorCallback
+  ): Promise<AccountInfo[] | undefined>;
+
+  getIncludedGroup(
+    groupName: GroupId | GroupName
+  ): Promise<GroupInfo[] | undefined>;
+
+  saveGroupMember(
+    groupName: GroupId | GroupName,
+    groupMember: AccountId
+  ): Promise<AccountInfo>;
+
+  saveIncludedGroup(
+    groupName: GroupId | GroupName,
+    includedGroup: GroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupInfo | undefined>;
+
+  deleteGroupMember(
+    groupName: GroupId | GroupName,
+    groupMember: AccountId
+  ): Promise<Response>;
+
+  deleteIncludedGroup(
+    groupName: GroupId | GroupName,
+    includedGroup: GroupId
+  ): Promise<Response>;
+
+  runRepoGC(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined>;
+  getFileContent(
+    changeNum: NumericChangeId,
+    path: string,
+    patchNum: PatchSetNum
+  ): Promise<Response | Base64FileContent | undefined>;
+
+  saveChangeEdit(
+    changeNum: NumericChangeId,
+    path: string,
+    contents: string
+  ): Promise<Response>;
+  getRepoTags(
+    filter: string,
+    repo: RepoName,
+    reposTagsPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<TagInfo[]>;
+
+  setRepoHead(repo: RepoName, ref: GitRef): Promise<Response>;
+  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
+  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
+  saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
+
+  getRelatedChanges(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<RelatedChangesInfo | undefined>;
+
+  getChangesSubmittedTogether(
+    changeNum: NumericChangeId
+  ): Promise<SubmittedTogetherInfo | undefined>;
+
+  getChangeConflicts(
+    changeNum: NumericChangeId
+  ): Promise<ChangeInfo[] | undefined>;
+
+  getChangeCherryPicks(
+    project: RepoName,
+    changeID: ChangeId,
+    changeNum: NumericChangeId
+  ): Promise<ChangeInfo[] | undefined>;
+
+  getChangesWithSameTopic(
+    topic: string,
+    changeNum: NumericChangeId
+  ): Promise<ChangeInfo[] | undefined>;
+
+  hasPendingDiffDrafts(): number;
+  awaitPendingDiffDrafts(): Promise<void>;
+
+  getRobotCommentFixPreview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixId: FixId
+  ): Promise<FilePathToDiffInfoMap | undefined>;
+
+  applyFixSuggestion(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixId: string
+  ): Promise<Response>;
+
+  getDiff(
+    changeNum: NumericChangeId,
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    path: string,
+    whitespace?: IgnoreWhitespaceType,
+    errFn?: ErrorCallback
+  ): Promise<DiffInfo | undefined>;
+
+  getBlame(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    base?: boolean
+  ): Promise<BlameInfo[] | undefined>;
+
+  getImagesForDiff(
+    changeNum: NumericChangeId,
+    diff: DiffInfo,
+    patchRange: PatchRange
+  ): Promise<ImagesForDiff>;
+
+  getChangeRevisionActions(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<ActionNameToActionInfoMap | undefined>;
+
+  confirmEmail(token: string): Promise<string | null>;
+
+  getDefaultPreferences(): Promise<PreferencesInfo | undefined>;
+
+  addAccountEmail(email: string): Promise<Response>;
+
+  addAccountEmail(
+    email: string,
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  saveChangeReviewed(
+    changeNum: NumericChangeId,
+    reviewed: boolean
+  ): Promise<Response | undefined>;
+
+  saveChangeStarred(
+    changeNum: NumericChangeId,
+    starred: boolean
+  ): Promise<Response>;
+
+  getDashboard(
+    project: RepoName,
+    dashboard: DashboardId,
+    errFn?: ErrorCallback
+  ): Promise<DashboardInfo | undefined>;
+
+  deleteDraftComments(query: string): Promise<Response>;
+
+  setAssignee(
+    changeNum: NumericChangeId,
+    assignee: AccountId
+  ): Promise<Response>;
+
+  deleteAssignee(changeNum: NumericChangeId): Promise<Response>;
+
+  setChangeHashtag(
+    changeNum: NumericChangeId,
+    hashtag: HashtagsInput
+  ): Promise<Hashtag[]>;
+
+  setChangeTopic(
+    changeNum: NumericChangeId,
+    topic: string | null
+  ): Promise<string>;
+
+  getChangeFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined>;
+
+  getChangeOrEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined>;
+
+  getReviewedFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<string[] | undefined>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean
+  ): Promise<Response>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
+
+  setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
+  getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
+
+  putChangeCommitMessage(
+    changeNum: NumericChangeId,
+    message: string
+  ): Promise<Response>;
+
+  getChangeCommitInfo(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<CommitInfo | undefined>;
+}
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.js b/polygerrit-ui/app/styles/dashboard-header-styles.js
deleted file mode 100644
index 683202e..0000000
--- a/polygerrit-ui/app/styles/dashboard-header-styles.js
+++ /dev/null
@@ -1,58 +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.
- */
-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/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
new file mode 100644
index 0000000..2354f65
--- /dev/null
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -0,0 +1,63 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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/gr-change-list-styles.js b/polygerrit-ui/app/styles/gr-change-list-styles.js
deleted file mode 100644
index 4f4d7e3..0000000
--- a/polygerrit-ui/app/styles/gr-change-list-styles.js
+++ /dev/null
@@ -1,194 +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.
- */
-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-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
new file mode 100644
index 0000000..6de4e6f
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -0,0 +1,210 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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);
+      }
+      gr-change-list-item[highlight] {
+        background-color: var(--assignee-highlight-color);
+      }
+      gr-change-list-item[highlight][selected],
+      gr-change-list-item[highlight]:focus {
+        background-color: var(--assignee-highlight-selection-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;
+      }
+      .owner {
+        --account-max-length: 120px;
+      }
+      .branch,
+      .star,
+      .label,
+      .number,
+      .owner,
+      .assignee,
+      .updated,
+      .submitted,
+      .waiting,
+      .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 {
+          overflow: hidden;
+          max-width: 18rem;
+          text-overflow: ellipsis;
+        }
+        .truncatedRepo {
+          display: inline-block;
+        }
+        .fullRepo {
+          display: none;
+        }
+      }
+      @media only screen and (max-width: 100em) {
+        .assignee,
+        .branch {
+          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,
+        .submitted,
+        .waiting,
+        .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.js b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js
deleted file mode 100644
index aabdde5..0000000
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js
+++ /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.
- */
-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-metadata-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
new file mode 100644
index 0000000..3d07d2e
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
@@ -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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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.js b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js
deleted file mode 100644
index 4bfb742..0000000
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js
+++ /dev/null
@@ -1,73 +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.
- */
-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-change-view-integration-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
new file mode 100644
index 0000000..57c8d78
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
@@ -0,0 +1,78 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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.js b/polygerrit-ui/app/styles/gr-form-styles.js
deleted file mode 100644
index 91763c5..0000000
--- a/polygerrit-ui/app/styles/gr-form-styles.js
+++ /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.
- */
-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-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
new file mode 100644
index 0000000..3284ad5
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -0,0 +1,132 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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.js b/polygerrit-ui/app/styles/gr-menu-page-styles.js
deleted file mode 100644
index e52a895..0000000
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.js
+++ /dev/null
@@ -1,82 +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.
- */
-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-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
new file mode 100644
index 0000000..8e8b264
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -0,0 +1,87 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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.js b/polygerrit-ui/app/styles/gr-page-nav-styles.js
deleted file mode 100644
index 97f1a03..0000000
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.js
+++ /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.
- */
-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-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
new file mode 100644
index 0000000..9010b2d
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -0,0 +1,80 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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.js b/polygerrit-ui/app/styles/gr-subpage-styles.js
deleted file mode 100644
index f94cc9c..0000000
--- a/polygerrit-ui/app/styles/gr-subpage-styles.js
+++ /dev/null
@@ -1,45 +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.
- */
-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-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts
new file mode 100644
index 0000000..640da66
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -0,0 +1,50 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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.js b/polygerrit-ui/app/styles/gr-table-styles.js
deleted file mode 100644
index ceac675..0000000
--- a/polygerrit-ui/app/styles/gr-table-styles.js
+++ /dev/null
@@ -1,119 +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.
- */
-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-table-styles.ts b/polygerrit-ui/app/styles/gr-table-styles.ts
new file mode 100644
index 0000000..52fdc67
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-table-styles.ts
@@ -0,0 +1,124 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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.js b/polygerrit-ui/app/styles/gr-voting-styles.js
deleted file mode 100644
index 4860428..0000000
--- a/polygerrit-ui/app/styles/gr-voting-styles.js
+++ /dev/null
@@ -1,42 +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.
- */
-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/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
new file mode 100644
index 0000000..d4e6d52
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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;
+          color: var(--vote-text-color);
+        }
+      }
+    </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.js b/polygerrit-ui/app/styles/shared-styles.js
deleted file mode 100644
index f5e048f..0000000
--- a/polygerrit-ui/app/styles/shared-styles.js
+++ /dev/null
@@ -1,199 +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.
- */
-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/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
new file mode 100644
index 0000000..695ae24
--- /dev/null
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -0,0 +1,218 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+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);
+      }
+      .heading-1 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h1);
+        font-weight: var(--font-weight-h1);
+        line-height: var(--line-height-h1);
+      }
+      .heading-2 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+      }
+      .heading-3 {
+        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. */
+
+      :host([hidden]),
+      [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);
+      }
+
+      .assistive-tech-only {
+        user-select: none;
+        clip: rect(1px, 1px, 1px, 1px);
+        height: 1px;
+        margin: 0;
+        overflow: hidden;
+        padding: 0;
+        position: absolute;
+        white-space: nowrap;
+        width: 1px;
+        z-index: -1000;
+      }
+
+      /** 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.js b/polygerrit-ui/app/styles/themes/app-theme.js
deleted file mode 100644
index 5d5d9e3..0000000
--- a/polygerrit-ui/app/styles/themes/app-theme.js
+++ /dev/null
@@ -1,220 +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.
- */
-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/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
new file mode 100644
index 0000000..8e362ab8
--- /dev/null
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -0,0 +1,245 @@
+/**
+ * @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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+import {
+  createStyle,
+  safeStyleSheet,
+  setInnerHtml,
+} from '../../utils/inner-html-util';
+
+const customStyle = document.createElement('custom-style');
+customStyle.setAttribute('id', 'light-theme');
+
+const styleSheet = safeStyleSheet`
+  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;
+    --chip-selected-text-color: var(--default-button-text-color);
+    --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;
+    --vote-text-color: black;
+    --status-text-color: white;
+    --tooltip-text-color: white;
+    --negative-red-text-color: #d93025;
+    --positive-green-text-color: #188038;
+
+    /* 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;
+    /* TODO: Find a nicer way to combine the --assignee-highlight-color and the
+       --selection-background-color than to just invent another unique color. */
+    --assignee-highlight-selection-color: #f6f4d0;
+    --chip-selected-background-color: #e8f0fe;
+    --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;
+
+    /* status colors */
+    --status-merged: #188038;
+    --status-abandoned: #5f6368;
+    --status-wip: #795548;
+    --status-private: #a142f4;
+    --status-conflict: #d93025;
+    --status-active: #1976d2;
+    --status-ready: #b80672;
+    --status-custom: #681da8;
+
+    /* 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.143rem;   /* 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;
+    --context-control-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
+
+    /* 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-moved-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-property-color: #fa8602;
+    --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;
+    }
+  }`;
+
+setInnerHtml(customStyle, createStyle(styleSheet));
+
+document.head.appendChild(customStyle);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
deleted file mode 100644
index 4248878..0000000
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ /dev/null
@@ -1,145 +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="dark-theme">
-  <custom-style><style is="custom-style">
-    html {
-      /**
-       * Sections and variables must stay consistent with app-theme.html.
-       *
-       * Only modify color variables in this theme file. dark-theme extends
-       * app-theme, so there is no need to repeat all variables, but for colors
-       * it does make sense to list them all: If you override one color, then
-       * you probably want to override all.
-       */
-
-      /* text colors */
-      --primary-text-color: #e8eaed;
-      --link-color: #8ab4f8;
-      --comment-text-color: var(--primary-text-color);
-      --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;
-      --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;
-      --edit-mode-background-color: #5c0a36;
-      --emphasis-color: #383f4a;
-      --hover-background-color: rgba(161, 194, 250, 0.2);
-      --disabled-button-background-color: #484a4d;
-      --primary-button-background-color: var(--link-color);
-      --selection-background-color: rgba(161, 194, 250, 0.1);
-      --tooltip-background-color: #111;
-      /* 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;
-      --vote-color-recommended: #3f6732;
-      --vote-color-rejected: #ac2d3e;
-
-      /* misc colors */
-      --border-color: #5f6368;
-      --comment-separator-color: var(--border-color);
-
-      /* fonts */
-      --font-weight-bold: 700; /* 700 is the same as 'bold' */
-
-      /* spacing */
-
-      /* header and footer */
-      --footer-background-color: var(--background-color-tertiary);
-      --footer-border-top: 1px solid var(--border-color);
-      --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);
-
-      /* diff colors */
-      --dark-add-highlight-color: #133820;
-      --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: 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);
-      --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
-      --diff-selection-background-color: #3a71d8;
-      --diff-tab-indicator-color: var(--deemphasized-text-color);
-      --diff-trailing-whitespace-indicator: #ff9ad2;
-      --light-add-highlight-color: #0f401f;
-      --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;
-      --syntax-attribute-color: var(--primary-text-color);
-      --syntax-built_in-color: #f7c369;
-      --syntax-comment-color: var(--deemphasized-text-color);
-      --syntax-default-color: var(--primary-text-color);
-      --syntax-doctag-weight: bold;
-      --syntax-function-color: var(--primary-text-color);
-      --syntax-keyword-color: #cd4cf0;
-      --syntax-link-color: #c792ea;
-      --syntax-literal-color: #eefff7;
-      --syntax-meta-color: #6d7eee;
-      --syntax-meta-keyword-color: #eefff7;
-      --syntax-number-color: #00998a;
-      --syntax-params-color: var(--primary-text-color);
-      --syntax-regexp-color: #f77669;
-      --syntax-selector-attr-color: #80cbbf;
-      --syntax-selector-class-color: #ffcb68;
-      --syntax-selector-id-color: #f77669;
-      --syntax-selector-pseudo-color: #c792ea;
-      --syntax-string-color: #c3e88d;
-      --syntax-tag-color: #f77669;
-      --syntax-template-tag-color: #c792ea;
-      --syntax-template-variable-color: #f77669;
-      --syntax-title-color: #75a5ff;
-      --syntax-type-color: #dd5f5f;
-      --syntax-variable-color: #f77669;
-
-      /* misc */
-
-      /* paper and iron component overrides */
-      --iron-overlay-backdrop-background-color: white;
-
-      /* rules applied to <html> */
-      background-color: var(--view-background-color);
-    }
-  </style></custom-style>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
new file mode 100644
index 0000000..0b54da4
--- /dev/null
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -0,0 +1,186 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  createStyle,
+  safeStyleSheet,
+  setInnerHtml,
+} from '../../utils/inner-html-util';
+
+function getStyleEl() {
+  const customStyle = document.createElement('custom-style');
+  customStyle.setAttribute('id', 'dark-theme');
+
+  const styleSheet = safeStyleSheet`
+    html {
+      /**
+       * Sections and variables must stay consistent with app-theme.js.
+       *
+       * Only modify color variables in this theme file. dark-theme extends
+       * app-theme, so there is no need to repeat all variables, but for colors
+       * it does make sense to list them all: If you override one color, then
+       * you probably want to override all.
+       */
+
+      /* text colors */
+      --primary-text-color: #e8eaed;
+      --link-color: #8ab4f8;
+      --comment-text-color: var(--primary-text-color);
+      --deemphasized-text-color: #9aa0a6;
+      --default-button-text-color: #8ab4f8;
+      --chip-selected-text-color: #d2e3fc;
+      --error-text-color: red;
+      --primary-button-text-color: black;
+        /* Used on text color for change list doesn't need user's attention. */
+      --reviewed-text-color: #dadce0;
+      --vote-text-color: black;
+      --status-text-color: black;
+      --tooltip-text-color: white;
+      --negative-red-text-color: #f28b82;
+      --positive-green-text-color: #81c995;
+
+      /* 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;
+      --assignee-highlight-selection-color: #423e24;
+      --chip-selected-background-color: #3c4455;
+      --edit-mode-background-color: #5c0a36;
+      --emphasis-color: #383f4a;
+      --hover-background-color: rgba(161, 194, 250, 0.2);
+      --disabled-button-background-color: #484a4d;
+      --primary-button-background-color: var(--link-color);
+      --selection-background-color: rgba(161, 194, 250, 0.1);
+      --tooltip-background-color: #111;
+      /* 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;
+      --vote-color-recommended: #3f6732;
+      --vote-color-rejected: #ac2d3e;
+
+      /* misc colors */
+      --border-color: #5f6368;
+      --comment-separator-color: var(--border-color);
+
+      /* status colors */
+      --status-merged: #5bb974;
+      --status-abandoned: #dadce0;
+      --status-wip: #bcaaa4;
+      --status-private: #d7aefb;
+      --status-conflict: #f28b82;
+      --status-active: #669df6;
+      --status-ready: #f439a0;
+      --status-custom: #af5cf7;
+
+      /* fonts */
+      --font-weight-bold: 700; /* 700 is the same as 'bold' */
+
+      /* spacing */
+
+      /* header and footer */
+      --footer-background-color: var(--background-color-tertiary);
+      --footer-border-top: 1px solid var(--border-color);
+      --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);
+
+      /* diff colors */
+      --dark-add-highlight-color: #133820;
+      --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: 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);
+      --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
+      --diff-selection-background-color: #3a71d8;
+      --diff-tab-indicator-color: var(--deemphasized-text-color);
+      --diff-trailing-whitespace-indicator: #ff9ad2;
+      --light-add-highlight-color: #0f401f;
+      --light-rebased-add-highlight-color: #487165;
+      --light-moved-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;
+      --syntax-attribute-color: var(--primary-text-color);
+      --syntax-built_in-color: #f7c369;
+      --syntax-comment-color: var(--deemphasized-text-color);
+      --syntax-default-color: var(--primary-text-color);
+      --syntax-doctag-weight: bold;
+      --syntax-function-color: var(--primary-text-color);
+      --syntax-keyword-color: #cd4cf0;
+      --syntax-link-color: #c792ea;
+      --syntax-literal-color: #eefff7;
+      --syntax-meta-color: #6d7eee;
+      --syntax-meta-keyword-color: #eefff7;
+      --syntax-number-color: #00998a;
+      --syntax-params-color: var(--primary-text-color);
+      --syntax-regexp-color: #f77669;
+      --syntax-selector-attr-color: #80cbbf;
+      --syntax-selector-class-color: #ffcb68;
+      --syntax-selector-id-color: #f77669;
+      --syntax-selector-pseudo-color: #c792ea;
+      --syntax-property-color: #c792ea;
+      --syntax-string-color: #c3e88d;
+      --syntax-tag-color: #f77669;
+      --syntax-template-tag-color: #c792ea;
+      --syntax-template-variable-color: #f77669;
+      --syntax-title-color: #75a5ff;
+      --syntax-type-color: #dd5f5f;
+      --syntax-variable-color: #f77669;
+
+      /* misc */
+
+      /* paper and iron component overrides */
+      --iron-overlay-backdrop-background-color: white;
+
+      /* rules applied to html */
+      background-color: var(--view-background-color);
+    }
+  `;
+
+  setInnerHtml(customStyle, createStyle(styleSheet));
+  return customStyle;
+}
+
+export function applyTheme() {
+  document.head.appendChild(getStyleEl());
+}
+
+export function removeTheme() {
+  const darkThemeEls = document.head.querySelectorAll('#dark-theme');
+  if (darkThemeEls.length) {
+    darkThemeEls.forEach(darkThemeEl => darkThemeEl.remove());
+  }
+}
diff --git a/polygerrit-ui/app/styles/themes/dark-theme_test.js b/polygerrit-ui/app/styles/themes/dark-theme_test.js
new file mode 100644
index 0000000..4f6466f
--- /dev/null
+++ b/polygerrit-ui/app/styles/themes/dark-theme_test.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 '../../test/common-test-setup-karma.js';
+import {applyTheme, removeTheme} from './dark-theme.js';
+
+suite('dark-theme_test.js', () => {
+  test('apply and remove theme', () => {
+    applyTheme();
+    assert.equal(document.head.querySelectorAll('#dark-theme').length, 1);
+    removeTheme();
+    assert.equal(document.head.querySelectorAll('#dark-theme').length, 0);
+  });
+});
diff --git a/polygerrit-ui/app/test/@types/sinon-esm.d.ts b/polygerrit-ui/app/test/@types/sinon-esm.d.ts
new file mode 100644
index 0000000..9074a7a
--- /dev/null
+++ b/polygerrit-ui/app/test/@types/sinon-esm.d.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.
+ */
+
+declare module 'sinon/pkg/sinon-esm' {
+  // sinon-esm doesn't have it's own d.ts, reexport all types from sinon
+  // This is a trick - @types/sinon adds interfaces and sinon instance
+  // to a global variables/namespace. We reexport it here, so we
+  // can use in our code when importing sinon-esm
+  // eslint-disable-next-line import/no-default-export
+  export default sinon;
+  const sinon: Sinon.SinonStatic;
+  export {SinonSpy, SinonFakeTimers, SinonStubbedMember};
+}
diff --git a/polygerrit-ui/app/test/a11y-test-utils.js b/polygerrit-ui/app/test/a11y-test-utils.js
new file mode 100644
index 0000000..a687e07
--- /dev/null
+++ b/polygerrit-ui/app/test/a11y-test-utils.js
@@ -0,0 +1,44 @@
+/**
+ * @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 './common-test-setup-karma.js';
+
+// Run a11y audit on test fixture
+// The code is inspired by the
+// https://github.com/Polymer/web-component-tester/blob/master/data/a11ySuite.js
+export async function runA11yAudit(fixture, ignoredRules) {
+  fixture.instantiate();
+  await flush();
+  const axsConfig = new axs.AuditConfiguration();
+  axsConfig.scope = document.body;
+  axsConfig.showUnsupportedRulesWarning = false;
+  axsConfig.auditRulesToIgnore = ignoredRules;
+
+  const auditResults = axs.Audit.run(axsConfig);
+  const errors = [];
+  auditResults.forEach((result, index) => {
+    // only show applicable tests
+    if (result.result === 'FAIL') {
+      const title = result.rule.heading;
+      // fail test if audit result is FAIL
+      const error = axs.Audit.accessibilityErrorMessage(result);
+      errors.push(`${title}: ${error}`);
+    }
+  });
+  if (errors.length > 0) {
+    assert.fail(errors.join('\n') + '\n');
+  }
+}
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.ts b/polygerrit-ui/app/test/common-test-setup-karma.ts
new file mode 100644
index 0000000..3d07d8a
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup-karma.ts
@@ -0,0 +1,207 @@
+/**
+ * @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 './common-test-setup';
+import '@polymer/test-fixture/test-fixture';
+import 'chai/chai';
+
+declare global {
+  interface Window {
+    flush: typeof flushImpl;
+    fixtureFromTemplate: typeof fixtureFromTemplateImpl;
+    fixtureFromElement: typeof fixtureFromElementImpl;
+  }
+  let flush: typeof flushImpl;
+  let fixtureFromTemplate: typeof fixtureFromTemplateImpl;
+  let fixtureFromElement: typeof fixtureFromElementImpl;
+}
+
+// Workaround for https://github.com/karma-runner/karma-mocha/issues/227
+let unhandledError: ErrorEvent;
+
+window.addEventListener('error', e => {
+  // For uncaught error mochajs doesn't print the full stack trace.
+  // We should print it ourselves.
+  console.error('Uncaught error:');
+  console.error(e.error.stack.toString());
+  unhandledError = e;
+});
+
+let originalOnBeforeUnload: typeof window.onbeforeunload;
+
+suiteSetup(() => {
+  // This suiteSetup() method is called only once before all tests
+
+  // Can't use window.addEventListener("beforeunload",...) here,
+  // the handler is raised too late.
+  originalOnBeforeUnload = window.onbeforeunload;
+  window.onbeforeunload = function (e: BeforeUnloadEvent) {
+    // If a test reloads a page, we can't prevent it.
+    // However we can print earror and the stack trace with assert.fail
+    try {
+      throw new Error();
+    } catch (e) {
+      console.error('Page reloading attempt detected.');
+      console.error(e.stack.toString());
+    }
+    if (originalOnBeforeUnload) {
+      originalOnBeforeUnload.call(this, e);
+    }
+  };
+});
+
+suiteTeardown(() => {
+  // This suiteTeardown() method is called only once after all tests
+  window.onbeforeunload = originalOnBeforeUnload;
+  if (unhandledError) {
+    throw unhandledError;
+  }
+});
+
+// Tests can use fake timers (sandbox.useFakeTimers)
+// Keep the original one for use in test utils methods.
+const nativeSetTimeout = window.setTimeout;
+
+function flushImpl(): Promise<void>;
+function flushImpl(callback: () => void): void;
+/**
+ * Triggers a flush of any pending events, observations, etc and calls you back
+ * after they have been processed if callback is passed; otherwise returns
+ * promise.
+ */
+function flushImpl(callback?: () => void): Promise<void> | void {
+  // Ideally, this function would be a call to Polymer.dom.flush, but that
+  // doesn't support a callback yet
+  // (https://github.com/Polymer/polymer-dev/issues/851)
+  // The type is used only in one place, disable eslint warning instead of
+  // creating an interface
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  (window as any).Polymer.dom.flush();
+  if (callback) {
+    nativeSetTimeout(callback, 0);
+  } else {
+    return new Promise(resolve => {
+      nativeSetTimeout(resolve, 0);
+    });
+  }
+}
+
+self.flush = flushImpl;
+
+class TestFixtureIdProvider {
+  public static readonly instance: TestFixtureIdProvider = new TestFixtureIdProvider();
+
+  private fixturesCount = 1;
+
+  generateNewFixtureId() {
+    this.fixturesCount++;
+    return `fixture-${this.fixturesCount}`;
+  }
+}
+
+interface TagTestFixture<T extends Element> {
+  instantiate(model?: unknown): T;
+}
+
+class TestFixture {
+  constructor(private readonly fixtureId: string) {}
+
+  /**
+   * Create an instance of a fixture's template.
+   *
+   * @param model - see Data-bound sections at
+   *   https://www.webcomponents.org/element/@polymer/test-fixture
+   * @return - if the fixture's template contains
+   *   a single element, returns the appropriated instantiated element.
+   *   Otherwise, it return an array of all instantiated elements from the
+   *   template.
+   */
+  instantiate(model?: unknown): HTMLElement | HTMLElement[] {
+    // The window.fixture method is defined in common-test-setup.js
+    return window.fixture(this.fixtureId, model);
+  }
+}
+
+/**
+ * Wraps provided template to a test-fixture tag and adds test-fixture to
+ * the document. You can use the html function to create a template.
+ *
+ * Example:
+ * import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+ *
+ * // Create fixture at the root level of a test file
+ * const basicTestFixture = fixtureFromTemplate(html`
+ *   <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
+ *   <ul>
+ *    <li>A</li>
+ *    <li>B</li>
+ *    <li>C</li>
+ *    <li>D</li>
+ *   </ul>
+ * `);
+ * ...
+ * // Instantiate fixture when needed:
+ *
+ * suite('example') {
+ *   let elements;
+ *   setup(() => {
+ *     elements = basicTestFixture.instantiate();
+ *   });
+ * }
+ *
+ * @param template - a template for a fixture
+ */
+function fixtureFromTemplateImpl(template: HTMLTemplateElement): TestFixture {
+  const fixtureId = TestFixtureIdProvider.instance.generateNewFixtureId();
+  const testFixture = document.createElement('test-fixture');
+  testFixture.setAttribute('id', fixtureId);
+  testFixture.appendChild(template);
+  document.body.appendChild(testFixture);
+  return new TestFixture(fixtureId);
+}
+
+/**
+ * Wraps provided tag to a test-fixture/template tags and adds test-fixture
+ * to the document.
+ *
+ * Example:
+ *
+ * // Create fixture at the root level of a test file
+ * const basicTestFixture = fixtureFromElement('gr-diff-view');
+ * ...
+ * // Instantiate fixture when needed:
+ *
+ * suite('example') {
+ *   let element;
+ *   setup(() => {
+ *     element = basicTestFixture.instantiate();
+ *   });
+ * }
+ *
+ * @param tagName - a template for a fixture is <tagName></tagName>
+ */
+function fixtureFromElementImpl<T extends keyof HTMLElementTagNameMap>(
+  tagName: T
+): TagTestFixture<HTMLElementTagNameMap[T]> {
+  const template = document.createElement('template');
+  template.innerHTML = `<${tagName}></${tagName}>`;
+  return (fixtureFromTemplate(template) as unknown) as TagTestFixture<
+    HTMLElementTagNameMap[T]
+  >;
+}
+
+window.fixtureFromTemplate = fixtureFromTemplateImpl;
+window.fixtureFromElement = fixtureFromElementImpl;
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
deleted file mode 100644
index aea24da..0000000
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ /dev/null
@@ -1,105 +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.
- */
-import '../scripts/bundled-polymer.js';
-
-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/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
new file mode 100644
index 0000000..71c45f7
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -0,0 +1,201 @@
+/**
+ * @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 should be the first import to install handler before any other code
+import './source-map-support-install';
+// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
+// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
+import '../scripts/bundled-polymer';
+import '@polymer/iron-test-helpers/iron-test-helpers';
+import './test-router';
+import {_testOnlyInitAppContext} from './test-app-context-init';
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api';
+import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {
+  cleanupTestUtils,
+  getCleanupsCount,
+  registerTestCleanup,
+  TestKeyboardShortcutBinder,
+} from './test-utils';
+import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
+import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import sinon, {SinonSpy} from 'sinon/pkg/sinon-esm';
+import {safeTypesBridge} from '../utils/safe-types-util';
+import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
+import {initGlobalVariables} from '../elements/gr-app-global-var-init';
+import 'chai/chai';
+import {
+  _testOnly_defaultResinReportHandler,
+  installPolymerResin,
+} from '../scripts/polymer-resin-install';
+import {hasOwnProperty} from '../utils/common-util';
+
+declare global {
+  interface Window {
+    assert: typeof chai.assert;
+    expect: typeof chai.expect;
+    fixture: typeof fixtureImpl;
+    stub: typeof stubImpl;
+    sinon: typeof sinon;
+  }
+  let assert: typeof chai.assert;
+  let expect: typeof chai.expect;
+  let stub: typeof stubImpl;
+  let sinon: typeof sinon;
+}
+window.assert = chai.assert;
+window.expect = chai.expect;
+
+window.sinon = sinon;
+
+installPolymerResin(safeTypesBridge, (isViolation, fmt, ...args) => {
+  const log = _testOnly_defaultResinReportHandler;
+  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));
+  }
+});
+
+interface TestFixtureElement extends HTMLElement {
+  restore(): void;
+  create(model?: unknown): HTMLElement | HTMLElement[];
+}
+
+function getFixtureElementById(fixtureId: string) {
+  return document.getElementById(fixtureId) as TestFixtureElement;
+}
+
+// For karma always set our implementation
+// (karma doesn't provide the fixture method)
+function fixtureImpl(fixtureId: string, model: unknown) {
+  // This method is inspired by web-component-tester method
+  registerTestCleanup(() => getFixtureElementById(fixtureId).restore());
+  return getFixtureElementById(fixtureId).create(model);
+}
+
+window.fixture = fixtureImpl;
+
+setup(() => {
+  window.Gerrit = {};
+  initGlobalVariables();
+
+  // If the following asserts fails - then window.stub is
+  // overwritten by some other code.
+  assert.equal(getCleanupsCount(), 0);
+  // The following calls is nessecary to avoid influence of previously executed
+  // tests.
+  TestKeyboardShortcutBinder.push();
+  _testOnlyInitAppContext();
+  _testOnly_initGerritPluginApi();
+  const mgr = _testOnly_getShortcutManagerInstance();
+  assert.isTrue(mgr._testOnly_isEmpty());
+  const selection = document.getSelection();
+  if (selection) {
+    selection.removeAllRanges();
+  }
+  const pl = _testOnly_resetPluginLoader();
+  // For testing, always init with empty plugin list
+  // Since when serve in gr-app, we always retrieve the list
+  // from project config and init loading after that, all
+  // `awaitPluginsLoaded` will rely on that to kick off,
+  // in testing, we want to kick start this earlier.
+  // You still can manually call _testOnly_resetPluginLoader
+  // to reset this behavior if you need to test something specific.
+  pl.loadPlugins([]);
+  _testOnlyResetGrRestApiSharedObjects();
+  _testOnlyResetRestApi();
+});
+
+// For karma always set our implementation
+// (karma doesn't provide the stub method)
+function stubImpl<T extends keyof HTMLElementTagNameMap>(
+  tagName: T,
+  implementation: Partial<HTMLElementTagNameMap[T]>
+) {
+  // This method is inspired by web-component-tester method
+  const proto = document.createElement(tagName).constructor
+    .prototype as HTMLElementTagNameMap[T];
+  let key: keyof HTMLElementTagNameMap[T];
+  const stubs: SinonSpy[] = [];
+  for (key in implementation) {
+    if (hasOwnProperty(implementation, key)) {
+      stubs.push(sinon.stub(proto, key).callsFake(implementation[key]));
+    }
+  }
+  registerTestCleanup(() => {
+    stubs.forEach(stub => {
+      stub.restore();
+    });
+  });
+}
+
+window.stub = stubImpl;
+
+// Very simple function to catch unexpected elements in documents body.
+// It can't catch everything, but in most cases it is enough.
+function checkChildAllowed(element: Element) {
+  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
+  if (allowedTags.includes(element.tagName)) {
+    return;
+  }
+  if (element.tagName === 'TEST-FIXTURE') {
+    if (
+      element.children.length === 0 ||
+      (element.children.length === 1 &&
+        element.children[0].tagName === 'TEMPLATE')
+    ) {
+      return;
+    }
+    assert.fail(
+      `Test fixture
+        ${element.outerHTML}` +
+        "isn't resotred after the test is finished. Please ensure that " +
+        'restore() method is called for this test-fixture. Usually the call' +
+        'happens automatically.'
+    );
+    return;
+  }
+  if (
+    element.tagName === 'DIV' &&
+    element.id === 'gr-hovercard-container' &&
+    element.childNodes.length === 0
+  ) {
+    return;
+  }
+  assert.fail(
+    `The following node remains in document after the test:
+      ${element.tagName}
+      Outer HTML:
+      ${element.outerHTML}`
+  );
+}
+function checkGlobalSpace() {
+  for (const child of document.body.children) {
+    checkChildAllowed(child);
+  }
+}
+
+teardown(() => {
+  sinon.restore();
+  cleanupTestUtils();
+  TestKeyboardShortcutBinder.pop();
+  checkGlobalSpace();
+  // Clean Polymer debouncer queue, so next tests will not be affected.
+  flushDebouncers();
+});
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
deleted file mode 100644
index 63df0be..0000000
--- a/polygerrit-ui/app/test/index.html
+++ /dev/null
@@ -1,34 +0,0 @@
-<!-- Copyright (C) 2020 The Android Open Source Project -->
-<!-- -->
-<!-- Licensed under the Apache License, Version 2.0 (the "License"); -->
-<!-- you may not use this file except in compliance with the License. -->
-<!-- You may obtain a copy of the License at -->
-<!-- -->
-<!-- http://www.apache.org/licenses/LICENSE-2.0 -->
-<!-- -->
-<!-- Unless required by applicable law or agreed to in writing, software -->
-<!-- distributed under the License is distributed on an "AS IS" BASIS, -->
-<!-- WITHOUT 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>
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<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;
-  }
-</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/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
new file mode 100644
index 0000000..f2ca48c
--- /dev/null
+++ b/polygerrit-ui/app/test/mocks/comment-api.js
@@ -0,0 +1,71 @@
+/**
+ * @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';
+
+/**
+ * This is an "abstract" class for tests. The descendant must define a template
+ * for this element and a tagName - see createCommentApiMockWithTemplateElement below
+ */
+class CommentApiMock extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  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;
+        });
+  }
+}
+
+/**
+ * Creates a new element which is descendant of CommentApiMock with specified
+ * template. Additionally, the method registers a tagName for this element.
+ *
+ * Each tagName must be a unique accross all tests.
+ */
+export function createCommentApiMockWithTemplateElement(tagName, template) {
+  const elementClass = class extends CommentApiMock {
+    static get is() { return tagName; }
+
+    static get template() { return template; }
+  };
+  customElements.define(tagName, elementClass);
+  return elementClass;
+}
diff --git a/polygerrit-ui/app/test/mock-diff-response.js b/polygerrit-ui/app/test/mocks/diff-response.js
similarity index 100%
rename from polygerrit-ui/app/test/mock-diff-response.js
rename to polygerrit-ui/app/test/mocks/diff-response.js
diff --git a/polygerrit-ui/app/test/source-map-support-install.ts b/polygerrit-ui/app/test/source-map-support-install.ts
new file mode 100644
index 0000000..b8798e2
--- /dev/null
+++ b/polygerrit-ui/app/test/source-map-support-install.ts
@@ -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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and doesn't allow "declare global".
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+declare global {
+  interface Window {
+    sourceMapSupport: {
+      install(): void;
+    };
+  }
+}
+
+// The karma.conf.js file loads required module before any other modules
+// The source-map-support.js can't be imported with import ... statement
+window.sourceMapSupport.install();
diff --git a/polygerrit-ui/app/test/suite_conf.js b/polygerrit-ui/app/test/suite_conf.js
deleted file mode 100644
index 82870fe..0000000
--- a/polygerrit-ui/app/test/suite_conf.js
+++ /dev/null
@@ -1,40 +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.
- */
-
-/**
- * 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-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
new file mode 100644
index 0000000..7f19903
--- /dev/null
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -0,0 +1,37 @@
+/**
+ * @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.
+ */
+
+// Init app context before any other imports
+import {initAppContext} from '../services/app-context-init';
+import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
+import {AppContext, appContext} from '../services/app-context';
+
+export function _testOnlyInitAppContext() {
+  initAppContext();
+
+  function setMock<T extends keyof AppContext>(
+    serviceName: T,
+    setupMock: AppContext[T]
+  ) {
+    Object.defineProperty(appContext, serviceName, {
+      get() {
+        return setupMock;
+      },
+    });
+  }
+  setMock('reportingService', grReportingMock);
+}
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
new file mode 100644
index 0000000..3b464a5
--- /dev/null
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -0,0 +1,415 @@
+/**
+ * @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 {
+  AccountId,
+  AccountInfo,
+  AccountsConfigInfo,
+  ApprovalInfo,
+  AuthInfo,
+  BranchName,
+  ChangeConfigInfo,
+  ChangeId,
+  ChangeInfo,
+  ChangeInfoId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  ChangeViewChangeInfo,
+  CommentLinkInfo,
+  CommentLinks,
+  CommitId,
+  CommitInfo,
+  ConfigInfo,
+  DownloadInfo,
+  EditPatchSetNum,
+  GerritInfo,
+  EmailAddress,
+  GitPersonInfo,
+  GitRef,
+  InheritedBooleanInfo,
+  MaxObjectSizeLimitInfo,
+  MergeableInfo,
+  NumericChangeId,
+  PatchSetNum,
+  PluginConfigInfo,
+  PreferencesInfo,
+  RepoName,
+  Reviewers,
+  RevisionInfo,
+  SchemesInfoMap,
+  ServerInfo,
+  SubmitTypeInfo,
+  SuggestInfo,
+  Timestamp,
+  TimezoneOffset,
+  UserConfigInfo,
+  AccountDetailInfo,
+} from '../types/common';
+import {
+  AccountsVisibility,
+  AppTheme,
+  AuthType,
+  ChangeStatus,
+  DateFormat,
+  DefaultBase,
+  DefaultDisplayNameConfig,
+  DiffViewMode,
+  EmailStrategy,
+  InheritedBooleanInfoConfiguredValue,
+  MergeabilityComputationBehavior,
+  RevisionKind,
+  SubmitType,
+  TimeFormat,
+} from '../constants/constants';
+import {formatDate} from '../utils/date-util';
+import {GetDiffCommentsOutput} from '../services/services/gr-rest-api/gr-rest-api';
+import {AppElementChangeViewParams} from '../elements/gr-app-types';
+import {GerritView} from '../elements/core/gr-navigation/gr-navigation';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export function dateToTimestamp(date: Date): Timestamp {
+  const nanosecondSuffix = '.000000000';
+  return (formatDate(date, 'YYYY-MM-DD HH:mm:ss') +
+    nanosecondSuffix) as Timestamp;
+}
+
+export function createCommentLink(match = 'test'): CommentLinkInfo {
+  return {
+    match,
+  };
+}
+
+export function createInheritedBoolean(value = false): InheritedBooleanInfo {
+  return {
+    value,
+    configured_value: value
+      ? InheritedBooleanInfoConfiguredValue.TRUE
+      : InheritedBooleanInfoConfiguredValue.FALSE,
+  };
+}
+
+export function createMaxObjectSizeLimit(): MaxObjectSizeLimitInfo {
+  return {};
+}
+
+export function createSubmitType(
+  value: Exclude<SubmitType, SubmitType.INHERIT> = SubmitType.MERGE_IF_NECESSARY
+): SubmitTypeInfo {
+  return {
+    value,
+    configured_value: SubmitType.INHERIT,
+    inherited_value: value,
+  };
+}
+
+export function createCommentLinks(): CommentLinks {
+  return {};
+}
+
+export function createConfig(): ConfigInfo {
+  return {
+    private_by_default: createInheritedBoolean(),
+    work_in_progress_by_default: createInheritedBoolean(),
+    max_object_size_limit: createMaxObjectSizeLimit(),
+    default_submit_type: createSubmitType(),
+    submit_type: SubmitType.INHERIT,
+    commentlinks: createCommentLinks(),
+  };
+}
+
+export function createAccountWithId(id = 5): AccountInfo {
+  return {
+    _account_id: id as AccountId,
+  };
+}
+
+export function createAccountDetailWithId(id = 5): AccountDetailInfo {
+  return {
+    _account_id: id as AccountId,
+    registered_on: dateToTimestamp(new Date(2020, 10, 15, 14, 5, 8)),
+  };
+}
+
+export function createAccountWithEmail(email = 'test@'): AccountInfo {
+  return {
+    email: email as EmailAddress,
+  };
+}
+
+export function createAccountWithIdNameAndEmail(id = 5): AccountInfo {
+  return {
+    _account_id: id as AccountId,
+    email: `user-${id}@` as EmailAddress,
+    name: `User-${id}`,
+  };
+}
+
+export function createReviewers(): Reviewers {
+  return {};
+}
+
+export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
+export const TEST_BRANCH_ID: BranchName = 'test-branch' as BranchName;
+export const TEST_CHANGE_ID: ChangeId = 'TestChangeId' as ChangeId;
+export const TEST_CHANGE_INFO_ID: ChangeInfoId = `${TEST_PROJECT_NAME}~${TEST_BRANCH_ID}~${TEST_CHANGE_ID}` as ChangeInfoId;
+export const TEST_SUBJECT = 'Test subject';
+export const TEST_NUMERIC_CHANGE_ID = 42 as NumericChangeId;
+
+export const TEST_CHANGE_CREATED = new Date(2020, 1, 1, 1, 2, 3);
+export const TEST_CHANGE_UPDATED = new Date(2020, 10, 6, 5, 12, 34);
+
+export function createGitPerson(name = 'Test name'): GitPersonInfo {
+  return {
+    name,
+    email: `${name}@`,
+    date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
+    tz: 0 as TimezoneOffset,
+  };
+}
+
+export function createCommit(): CommitInfo {
+  return {
+    parents: [],
+    author: createGitPerson(),
+    committer: createGitPerson(),
+    subject: 'Test commit subject',
+    message: 'Test commit message',
+  };
+}
+
+export function createRevision(patchSetNum = 1): RevisionInfo {
+  return {
+    _number: patchSetNum as PatchSetNum,
+    commit: createCommit(),
+    created: dateToTimestamp(TEST_CHANGE_CREATED),
+    kind: RevisionKind.REWORK,
+    ref: 'refs/changes/5/6/1' as GitRef,
+    uploader: createAccountWithId(),
+  };
+}
+
+export function createEditRevision(): EditRevisionInfo {
+  return {
+    _number: EditPatchSetNum,
+    basePatchNum: 1 as PatchSetNum,
+    commit: createCommit(),
+  };
+}
+
+export function createChangeMessage(id = 'cm_id_1'): ChangeMessageInfo {
+  return {
+    id: id as ChangeMessageId,
+    date: dateToTimestamp(TEST_CHANGE_CREATED),
+    message: `This is a message with id ${id}`,
+  };
+}
+
+export function createRevisions(
+  count: number
+): {[revisionId: string]: RevisionInfo} {
+  const revisions: {[revisionId: string]: RevisionInfo} = {};
+  const revisionDate = TEST_CHANGE_CREATED;
+  const revisionIdStart = 1; // The same as getCurrentRevision
+  for (let i = 0; i < count; i++) {
+    const revisionId = (i + revisionIdStart).toString(16);
+    const revision: RevisionInfo = {
+      ...createRevision(i + 1),
+      created: dateToTimestamp(revisionDate),
+      ref: `refs/changes/5/6/${i + 1}` as GitRef,
+    };
+    revisions[revisionId] = revision;
+    // advance 1 day
+    revisionDate.setDate(revisionDate.getDate() + 1);
+  }
+  return revisions;
+}
+
+export function getCurrentRevision(count: number): CommitId {
+  const revisionIdStart = 1; // The same as createRevisions
+  return (count + revisionIdStart).toString(16) as CommitId;
+}
+
+export function createChangeMessages(count: number): ChangeMessageInfo[] {
+  const messageIdStart = 1000;
+  const messages: ChangeMessageInfo[] = [];
+  const messageDate = TEST_CHANGE_CREATED;
+  for (let i = 0; i < count; i++) {
+    messages.push({
+      ...createChangeMessage((i + messageIdStart).toString(16)),
+      date: dateToTimestamp(messageDate),
+    });
+    messageDate.setDate(messageDate.getDate() + 1);
+  }
+  return messages;
+}
+
+export function createChange(): ChangeInfo {
+  return {
+    id: TEST_CHANGE_INFO_ID,
+    project: TEST_PROJECT_NAME,
+    branch: TEST_BRANCH_ID,
+    change_id: TEST_CHANGE_ID,
+    subject: TEST_SUBJECT,
+    status: ChangeStatus.NEW,
+    created: dateToTimestamp(TEST_CHANGE_CREATED),
+    updated: dateToTimestamp(TEST_CHANGE_UPDATED),
+    insertions: 0,
+    deletions: 0,
+    _number: TEST_NUMERIC_CHANGE_ID,
+    owner: createAccountWithId(),
+    // This is documented as optional, but actually always set.
+    reviewers: createReviewers(),
+  };
+}
+
+export function createChangeViewChange(): ChangeViewChangeInfo {
+  return {
+    ...createChange(),
+    revisions: {
+      abc: createRevision(),
+    },
+    current_revision: 'abc' as CommitId,
+  };
+}
+
+export function createParsedChange(): ParsedChangeInfo {
+  return createChangeViewChange();
+}
+
+export function createAccountsConfig(): AccountsConfigInfo {
+  return {
+    visibility: AccountsVisibility.ALL,
+    default_display_name: DefaultDisplayNameConfig.FULL_NAME,
+  };
+}
+
+export function createAuth(): AuthInfo {
+  return {
+    auth_type: AuthType.OPENID,
+    editable_account_fields: [],
+  };
+}
+
+export function createChangeConfig(): ChangeConfigInfo {
+  return {
+    large_change: 500,
+    reply_label: 'Reply',
+    reply_tooltip: 'Reply and score',
+    // The default update_delay is 5 minutes, but we don't want to accidentally
+    // start polling in tests
+    update_delay: 0,
+    mergeability_computation_behavior:
+      MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX,
+    enable_attention_set: false,
+    enable_assignee: false,
+  };
+}
+
+export function createDownloadSchemes(): SchemesInfoMap {
+  return {};
+}
+
+export function createDownloadInfo(): DownloadInfo {
+  return {
+    schemes: createDownloadSchemes(),
+    archives: ['tgz', 'tar'],
+  };
+}
+
+export function createGerritInfo(): GerritInfo {
+  return {
+    all_projects: 'All-Projects',
+    all_users: 'All-Users',
+    doc_search: false,
+  };
+}
+
+export function createPluginConfig(): PluginConfigInfo {
+  return {
+    has_avatars: false,
+    js_resource_paths: [],
+    html_resource_paths: [],
+  };
+}
+
+export function createSuggestInfo(): SuggestInfo {
+  return {
+    from: 0,
+  };
+}
+
+export function createUserConfig(): UserConfigInfo {
+  return {
+    anonymous_coward_name: 'Name of user not set',
+  };
+}
+
+export function createServerInfo(): ServerInfo {
+  return {
+    accounts: createAccountsConfig(),
+    auth: createAuth(),
+    change: createChangeConfig(),
+    download: createDownloadInfo(),
+    gerrit: createGerritInfo(),
+    plugin: createPluginConfig(),
+    suggest: createSuggestInfo(),
+    user: createUserConfig(),
+  };
+}
+
+export function createGetDiffCommentsOutput(): GetDiffCommentsOutput {
+  return {
+    baseComments: [],
+    comments: [],
+  };
+}
+
+export function createMergeable(): MergeableInfo {
+  return {
+    submit_type: SubmitType.MERGE_IF_NECESSARY,
+    mergeable: false,
+  };
+}
+
+export function createPreferences(): PreferencesInfo {
+  return {
+    changes_per_page: 10,
+    theme: AppTheme.LIGHT,
+    date_format: DateFormat.ISO,
+    time_format: TimeFormat.HHMM_24,
+    diff_view: DiffViewMode.SIDE_BY_SIDE,
+    my: [],
+    change_table: [],
+    email_strategy: EmailStrategy.ENABLED,
+    default_base_for_merges: DefaultBase.AUTO_MERGE,
+  };
+}
+
+export function createApproval(): ApprovalInfo {
+  return createAccountWithId();
+}
+
+export function createAppElementChangeViewParams(): AppElementChangeViewParams {
+  return {
+    view: GerritView.CHANGE,
+    changeNum: TEST_NUMERIC_CHANGE_ID,
+    project: TEST_PROJECT_NAME,
+  };
+}
diff --git a/polygerrit-ui/app/test/test-router.js b/polygerrit-ui/app/test/test-router.js
deleted file mode 100644
index 9b89744..0000000
--- a/polygerrit-ui/app/test/test-router.js
+++ /dev/null
@@ -1,19 +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.
- */
-import {GerritNav} from '../elements/core/gr-navigation/gr-navigation.js';
-
-GerritNav.setup(url => { /* noop */ }, params => '', () => []);
diff --git a/polygerrit-ui/app/test/test-router.ts b/polygerrit-ui/app/test/test-router.ts
new file mode 100644
index 0000000..a378e2d
--- /dev/null
+++ b/polygerrit-ui/app/test/test-router.ts
@@ -0,0 +1,28 @@
+/**
+ * @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';
+
+GerritNav.setup(
+  () => {
+    /* noop */
+  },
+  () => '',
+  () => [],
+  () => {
+    return {};
+  }
+);
diff --git a/polygerrit-ui/app/test/test-utils.js b/polygerrit-ui/app/test/test-utils.js
deleted file mode 100644
index 77d8e22..0000000
--- a/polygerrit-ui/app/test/test-utils.js
+++ /dev/null
@@ -1,38 +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.
- */
-
-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/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
new file mode 100644
index 0000000..7ebc6e1
--- /dev/null
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -0,0 +1,154 @@
+/**
+ * @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 '../types/globals';
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils';
+import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
+import {
+  _testOnly_getShortcutManagerInstance,
+  Shortcut,
+} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+
+export interface MockPromise extends Promise<unknown> {
+  resolve: (value?: unknown) => void;
+}
+
+export const mockPromise = () => {
+  let res: (value?: unknown) => void;
+  const promise: MockPromise = new Promise(resolve => {
+    res = resolve;
+  }) as MockPromise;
+  promise.resolve = res!;
+  return promise;
+};
+
+export function isHidden(el: Element | undefined | null) {
+  if (!el) return true;
+  return getComputedStyle(el).display === 'none';
+}
+
+export function query(el: Element | undefined, selectors: string) {
+  if (!el) return null;
+  const root = el.shadowRoot || el;
+  return root.querySelector(selectors);
+}
+
+// Some tests/elements can define its own binding. We want to restore bindings
+// at the end of the test. The TestKeyboardShortcutBinder store bindings in
+// stack, so it is possible to override bindings in nested suites.
+export class TestKeyboardShortcutBinder {
+  private static stack: TestKeyboardShortcutBinder[] = [];
+
+  static push() {
+    const testBinder = new TestKeyboardShortcutBinder();
+    this.stack.push(testBinder);
+    return _testOnly_getShortcutManagerInstance();
+  }
+
+  static pop() {
+    const item = this.stack.pop();
+    if (!item) {
+      throw new Error('stack is empty');
+    }
+    item._restoreShortcuts();
+  }
+
+  private readonly originalBinding: Map<Shortcut, string[]>;
+
+  constructor() {
+    this.originalBinding = new Map(
+      _testOnly_getShortcutManagerInstance()._testOnly_getBindings()
+    );
+  }
+
+  _restoreShortcuts() {
+    const bindings = _testOnly_getShortcutManagerInstance()._testOnly_getBindings();
+    bindings.clear();
+    this.originalBinding.forEach((value, key) => {
+      bindings.set(key, value);
+    });
+  }
+}
+
+// Provide reset plugins function to clear installed plugins between tests.
+// No gr-app found (running tests)
+export const resetPlugins = () => {
+  testOnly_resetInternalState();
+  _testOnly_resetEndpoints();
+  const pl = _testOnly_resetPluginLoader();
+  pl.loadPlugins([]);
+};
+
+export type CleanupCallback = () => void;
+
+const cleanups: CleanupCallback[] = [];
+
+export function getCleanupsCount() {
+  return cleanups.length;
+}
+
+export function registerTestCleanup(cleanupCallback: CleanupCallback) {
+  cleanups.push(cleanupCallback);
+}
+
+export function cleanupTestUtils() {
+  cleanups.forEach(cleanup => cleanup());
+  cleanups.splice(0);
+}
+
+export function stubBaseUrl(newUrl: string) {
+  const originalCanonicalPath = window.CANONICAL_PATH;
+  window.CANONICAL_PATH = newUrl;
+  registerTestCleanup(() => (window.CANONICAL_PATH = originalCanonicalPath));
+}
+
+/**
+ * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
+ * otherwise the backdrop stays around in the DOM for too long waiting for
+ * an animation to finish. This could be considered to be moved to a
+ * common-test-setup file.
+ */
+export function createIronOverlayBackdropStyleEl() {
+  const ironOverlayBackdropStyleEl = document.createElement('style');
+  document.head.appendChild(ironOverlayBackdropStyleEl);
+  ironOverlayBackdropStyleEl.sheet!.insertRule(
+    'body { --iron-overlay-backdrop-opacity: 0; }'
+  );
+  return ironOverlayBackdropStyleEl;
+}
+
+/**
+ * Promisify an event callback to simplify async...await tests.
+ *
+ * Use like this:
+ *   await listenOnce(el, 'render');
+ *   ...
+ */
+export function listenOnce(el: EventTarget, eventType: string) {
+  return new Promise(resolve => {
+    const listener = () => {
+      removeEventListener();
+      resolve();
+    };
+    el.addEventListener(eventType, listener);
+    let removeEventListener = () => {
+      el.removeEventListener(eventType, listener);
+      removeEventListener = () => {};
+    };
+    registerTestCleanup(removeEventListener);
+  });
+}
diff --git a/polygerrit-ui/app/test/tests.js b/polygerrit-ui/app/test/tests.js
deleted file mode 100644
index 795949d..0000000
--- a/polygerrit-ui/app/test/tests.js
+++ /dev/null
@@ -1,339 +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.
- */
-
-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/tsconfig.json b/polygerrit-ui/app/tsconfig.json
new file mode 100644
index 0000000..15294f4
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig.json
@@ -0,0 +1,65 @@
+{
+  "compilerOptions": {
+    /* Basic Options */
+    "target": "es2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
+    "allowJs": true, /* Allow javascript files to be compiled. */
+    "checkJs": false, /* Report errors in .js files. */
+    "declaration": false, /* Temporary disabled - generates corresponding '.d.ts' file. */
+    "declarationMap": false, /* Generates a sourcemap for each corresponding '.d.ts' file. */
+    "inlineSourceMap": true, /* Generates corresponding '.map' file. */
+    "outDir": "../../.ts-out/polygerrit-ui/app", /* Not used in bazel. Redirect output structure to the directory. */
+    "rootDir": ".", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+    "removeComments": false, /* Emit comments to output*/
+
+    /* 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. */
+
+    /* 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. */
+
+    "skipLibCheck": true, /* Do not check node_modules */
+
+    /* Module Resolution Options */
+    "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+    "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. */
+
+    /* Advanced Options */
+    "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
+    "incremental": true,
+    "experimentalDecorators": true,
+
+    "allowUmdGlobalAccess": true
+  },
+  // With the * pattern (without an extension), only supported files
+  // are included. The supported files are .ts, .tsx, .d.ts.
+  // If allowJs is set to true, .js and .jsx files are included as well.
+  // Note: gerrit doesn't have .tsx and .jsx files
+  "include": [
+    // Items below must be in sync with the src_dirs list in the BUILD file
+    // Also items must be in sync with tsconfig_bazel.json, tsconfig_bazel_test.json
+    // (include and exclude arrays are overriden when extends)
+    "constants/**/*",
+    "elements/**/*",
+    "embed/**/*",
+    "gr-diff/**/*",
+    "mixins/**/*",
+    "samples/**/*",
+    "scripts/**/*",
+    "services/**/*",
+    "styles/**/*",
+    "types/**/*",
+    "utils/**/*",
+    "test/**/*"
+  ]
+}
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
new file mode 100644
index 0000000..6365bf0
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -0,0 +1,29 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "typeRoots": [
+      "../../external/ui_npm/node_modules/@types",
+      "../../external/ui_dev_npm/node_modules/@types"
+    ]
+  },
+  "include": [
+    // Items below must be in sync with the src_dirs list in the BUILD file
+    // Also items must be in sync with tsconfig.json, tsconfig_bazel_test.json
+    // (include and exclude arrays are overriden when extends)
+    "constants/**/*",
+    "elements/**/*",
+    "embed/**/*",
+    "gr-diff/**/*",
+    "mixins/**/*",
+    "samples/**/*",
+    "scripts/**/*",
+    "services/**/*",
+    "styles/**/*",
+    "types/**/*",
+    "utils/**/*"
+  ],
+  "exclude": [
+    "**/*_test.ts",
+    "**/*_test.js"
+  ]
+}
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
new file mode 100644
index 0000000..efd2978
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -0,0 +1,32 @@
+{
+  "extends": "./tsconfig_bazel.json",
+  "compilerOptions": {
+    "typeRoots": [
+      "./test/@types",
+      "../../external/ui_dev_npm/node_modules/@polymer/iron-test-helpers",
+      "../../external/ui_npm/node_modules/@types",
+      "../../external/ui_dev_npm/node_modules/@types"
+    ],
+    "paths": {
+      "@polymer/iron-test-helpers/*": ["../../ui_dev_npm/node_modules/@polymer/iron-test-helpers/*"]
+    }
+  },
+  "include": [
+    // Items below must be in sync with the src_dirs list in the BUILD file
+    // Also items must be in sync with tsconfig.json, tsconfig_test.json
+    // (include and exclude arrays are overriden when extends)
+    "constants/**/*",
+    "elements/**/*",
+    "embed/**/*",
+    "gr-diff/**/*",
+    "mixins/**/*",
+    "samples/**/*",
+    "scripts/**/*",
+    "services/**/*",
+    "styles/**/*",
+    "types/**/*",
+    "utils/**/*",
+    "test/**/*"
+  ],
+  "exclude": []
+}
diff --git a/polygerrit-ui/app/tsconfig_eslint.json b/polygerrit-ui/app/tsconfig_eslint.json
new file mode 100644
index 0000000..7cc99c7
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_eslint.json
@@ -0,0 +1,11 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "skipLibCheck": false, /* This is required for report-ts-error.js.
+    See details in the //tools/js/eslint-rules/report-ts-error.js file.*/
+    "baseUrl": "../../external/ui_npm/node_modules" /* Only for bazel.
+    Compiler will try to use it to resolve module name and if it fail - will
+    fallback to a default behavior
+    (https://github.com/microsoft/TypeScript/issues/5039)*/
+  }
+}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
new file mode 100644
index 0000000..94b075d
--- /dev/null
+++ b/polygerrit-ui/app/types/common.ts
@@ -0,0 +1,2256 @@
+/**
+ * @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 {
+  ChangeStatus,
+  DefaultDisplayNameConfig,
+  FileInfoStatus,
+  GpgKeyInfoStatus,
+  ProblemInfoStatus,
+  ProjectState,
+  RequirementStatus,
+  ReviewerState,
+  RevisionKind,
+  SubmitType,
+  InheritedBooleanInfoConfiguredValue,
+  ConfigParameterInfoType,
+  AccountTag,
+  PermissionAction,
+  HttpMethod,
+  CommentSide,
+  AppTheme,
+  DateFormat,
+  TimeFormat,
+  EmailStrategy,
+  DefaultBase,
+  IgnoreWhitespaceType,
+  UserPriority,
+  DiffViewMode,
+  DraftsAction,
+  NotifyType,
+  EmailFormat,
+  AuthType,
+  MergeStrategy,
+  EditableAccountField,
+  MergeabilityComputationBehavior,
+} from '../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+
+export type BrandType<T, BrandName extends string> = T &
+  {[__brand in BrandName]: never};
+
+/*
+ * In T, make a set of properties whose keys are in the union K required
+ */
+export type RequireProperties<T, K extends keyof T> = Omit<T, K> &
+  Required<Pick<T, K>>;
+
+export type PropertyType<T, K extends keyof T> = ReturnType<() => T[K]>;
+
+export type ElementPropertyDeepChange<
+  T,
+  K extends keyof T
+> = PolymerDeepPropertyChange<PropertyType<T, K>, PropertyType<T, K>>;
+
+/**
+ * Type alias for parsed json object to make code cleaner
+ */
+export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
+
+export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+
+export const EditPatchSetNum = 'edit' as PatchSetNum;
+// TODO(TS): This is not correct, it is better to have a separate ApiPatchSetNum
+// without 'parent'.
+export const ParentPatchSetNum = 'PARENT' as PatchSetNum;
+
+export type ChangeId = BrandType<string, '_changeId'>;
+export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
+export type NumericChangeId = BrandType<number, '_numericChangeId'>;
+export type RepoName = BrandType<string, '_repoName'>;
+export type UrlEncodedRepoName = BrandType<string, '_urlEncodedRepoName'>;
+export type TopicName = BrandType<string, '_topicName'>;
+// TODO(TS): Probably, we should separate AccountId and EncodedAccountId
+export type AccountId = BrandType<number, '_accountId'>;
+export type GitRef = BrandType<string, '_gitRef'>;
+export type RequirementType = BrandType<string, '_requirementType'>;
+export type TrackingId = BrandType<string, '_trackingId'>;
+export type ReviewInputTag = BrandType<string, '_reviewInputTag'>;
+export type RobotId = BrandType<string, '_robotId'>;
+export type RobotRunId = BrandType<string, '_robotRunId'>;
+
+// RevisionId '0' is the same as 'current'. However, we want to avoid '0'
+// in our code, so it is not added here as a possible value.
+export type RevisionId = 'current' | CommitId | PatchSetNum;
+
+// The UUID of the suggested fix.
+export type FixId = BrandType<string, '_fixId'>;
+export type EmailAddress = BrandType<string, '_emailAddress'>;
+
+// The URL encoded UUID of the comment
+export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
+
+// The ID of the dashboard, in the form of '<ref>:<path>'
+export type DashboardId = BrandType<string, '_dahsboardId'>;
+
+// The 8-char hex GPG key ID.
+export type GpgKeyId = BrandType<string, '_gpgKeyId'>;
+
+// The 40-char (plus spaces) hex GPG key fingerprint
+export type GpgKeyFingerprint = BrandType<string, '_gpgKeyFingerprint'>;
+
+// OpenPGP User IDs (https://tools.ietf.org/html/rfc4880#section-5.11).
+export type OpenPgpUserIds = BrandType<string, '_openPgpUserIds'>;
+
+// 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.
+export type ChangeSubmissionId = BrandType<
+  string | number,
+  '_changeSubmissionId'
+>;
+
+// The refs/heads/ prefix is omitted in Branch name
+export type BranchName = BrandType<string, '_branchName'>;
+
+// The refs/tags/ prefix is omitted in Tag name
+export type TagName = BrandType<string, '_tagName'>;
+
+// The ID of the change in the format "'<project>~<branch>~<Change-Id>'"
+export type ChangeInfoId = BrandType<string, '_changeInfoId'>;
+export type Hashtag = BrandType<string, '_hashtag'>;
+export type StarLabel = BrandType<string, '_startLabel'>;
+export type CommitId = BrandType<string, '_commitId'>;
+export type LabelName = BrandType<string, '_labelName'>;
+export type GroupName = BrandType<string, '_groupName'>;
+
+// The UUID of the group
+export type GroupId = BrandType<string, '_groupId'>;
+
+// The Encoded UUID of the group
+export type EncodedGroupId = BrandType<string, '_encodedGroupId'>;
+
+// The timezone offset from UTC in minutes
+export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
+
+// Timestamps are given in UTC and have the format
+// "'yyyy-mm-dd hh:mm:ss.fffffffff'"
+// where "'ffffffffff'" represents nanoseconds.
+export type Timestamp = BrandType<string, '_timestamp'>;
+
+export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
+export type LabelNameToInfoMap = {[labelName: string]: LabelInfo};
+
+// {Verified: ["-1", " 0", "+1"]}
+export type LabelNameToValueMap = {[labelName: string]: string[]};
+
+// The map maps the values (“-2”, “-1”, " `0`", “+1”, “+2”) to the value descriptions.
+export type LabelValueToDescriptionMap = {[labelValue: string]: string};
+
+/**
+ * The LabelInfo entity contains information about a label on a change, always
+ * corresponding to the current patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
+ */
+export type LabelInfo =
+  | QuickLabelInfo
+  | DetailedLabelInfo
+  | (QuickLabelInfo & DetailedLabelInfo);
+
+interface LabelCommonInfo {
+  optional?: boolean; // not set if false
+}
+
+export interface QuickLabelInfo extends LabelCommonInfo {
+  approved?: AccountInfo;
+  rejected?: AccountInfo;
+  recommended?: AccountInfo;
+  disliked?: AccountInfo;
+  blocking?: boolean; // not set if false
+  value?: number; // The voting value of the user who recommended/disliked this label on the change if it is not “+1”/“-1”.
+  default_value?: number;
+}
+
+/**
+ * LabelInfo when DETAILED_LABELS are requested.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
+ */
+export interface DetailedLabelInfo extends LabelCommonInfo {
+  // This is not set when the change has no reviewers.
+  all?: ApprovalInfo[];
+  // Docs claim that 'values' is optional, but it is actually always set.
+  values: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+  default_value?: number;
+}
+
+export function isQuickLabelInfo(
+  l: LabelInfo
+): l is QuickLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  const quickLabelInfo = l as QuickLabelInfo;
+  return (
+    quickLabelInfo.approved !== undefined ||
+    quickLabelInfo.rejected !== undefined ||
+    quickLabelInfo.recommended !== undefined ||
+    quickLabelInfo.disliked !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.value !== undefined
+  );
+}
+
+export function isDetailedLabelInfo(
+  label: LabelInfo
+): label is DetailedLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  return !!(label as DetailedLabelInfo).values;
+}
+
+// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
+export interface ContributorAgreementInput {
+  name?: string;
+}
+
+// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
+export interface ContributorAgreementInfo {
+  name: string;
+  description: string;
+  url: string;
+  auto_verify_group?: GroupInfo;
+}
+
+/**
+ * The ChangeInfo entity contains information about a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+ */
+export interface ChangeInfo {
+  id: ChangeInfoId;
+  project: RepoName;
+  branch: BranchName;
+  topic?: TopicName;
+  attention_set?: IdToAttentionSetMap;
+  assignee?: AccountInfo;
+  hashtags?: Hashtag[];
+  change_id: ChangeId;
+  subject: string;
+  status: ChangeStatus;
+  created: Timestamp;
+  updated: Timestamp;
+  submitted?: Timestamp;
+  submitter?: AccountInfo;
+  starred?: boolean; // not set if false
+  stars?: StarLabel[];
+  reviewed?: boolean; // not set if false
+  submit_type?: SubmitType;
+  mergeable?: boolean;
+  submittable?: boolean;
+  insertions: number; // Number of inserted lines
+  deletions: number; // Number of deleted lines
+  total_comment_count?: number;
+  unresolved_comment_count?: number;
+  // TODO(TS): Use changed_id everywhere in code instead of (legacy) _number
+  _number: NumericChangeId;
+  owner: AccountInfo;
+  actions?: ActionNameToActionInfoMap;
+  requirements?: Requirement[];
+  labels?: LabelNameToInfoMap;
+  permitted_labels?: LabelNameToValueMap;
+  removable_reviewers?: AccountInfo[];
+  // This is documented as optional, but actually always set.
+  reviewers: Reviewers;
+  pending_reviewers?: AccountInfo[];
+  reviewer_updates?: ReviewerUpdateInfo[];
+  messages?: ChangeMessageInfo[];
+  current_revision?: CommitId;
+  revisions?: {[revisionId: string]: RevisionInfo};
+  tracking_ids?: TrackingIdInfo[];
+  _more_changes?: boolean; // not set if false
+  problems?: ProblemInfo[];
+  is_private?: boolean; // not set if false
+  work_in_progress?: boolean; // not set if false
+  has_review_started?: boolean; // not set if false
+  revert_of?: NumericChangeId;
+  submission_id?: ChangeSubmissionId;
+  cherry_pick_of_change?: NumericChangeId;
+  cherry_pick_of_patch_set?: PatchSetNum;
+  contains_git_conflicts?: boolean;
+  internalHost?: string; // TODO(TS): provide an explanation what is its
+}
+
+/**
+ * The reviewers as a map that maps a reviewer state to a list of AccountInfo
+ * entities. Possible reviewer states are REVIEWER, CC and REMOVED.
+ * REVIEWER: Users with at least one non-zero vote on the change.
+ * CC: Users that were added to the change, but have not voted.
+ * REMOVED: Users that were previously reviewers on the change, but have been removed.
+ */
+export type Reviewers = Partial<Record<ReviewerState, AccountInfo[]>>;
+
+/**
+ * ChangeView request change detail with ALL_REVISIONS option set.
+ * The response always contains current_revision and revisions.
+ */
+export type ChangeViewChangeInfo = RequireProperties<
+  ChangeInfo,
+  'current_revision' | 'revisions'
+>;
+/**
+ * The AccountInfo entity contains information about an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-info
+ */
+export interface AccountInfo {
+  // Normally _account_id is defined (for known Gerrit users), but users can
+  // also be CCed just with their email address. So you have to be prepared that
+  // _account_id is undefined, but then email must be set.
+  _account_id?: AccountId;
+  name?: string;
+  display_name?: string;
+  // Must be set, if _account_id is undefined.
+  email?: EmailAddress;
+  secondary_emails?: string[];
+  username?: string;
+  avatars?: AvatarInfo[];
+  _more_accounts?: boolean; // not set if false
+  status?: string; // status message of the account
+  inactive?: boolean; // not set if false
+  tags?: AccountTag[];
+}
+
+export function isAccount(x: AccountInfo | GroupInfo): x is AccountInfo {
+  const account = x as AccountInfo;
+  return account._account_id !== undefined || account.email !== undefined;
+}
+
+export function isGroup(x: AccountInfo | GroupInfo): x is GroupInfo {
+  return (x as GroupInfo).id !== undefined;
+}
+
+/**
+ * The AccountDetailInfo entity contains detailed information about an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-detail-info
+ */
+export interface AccountDetailInfo extends AccountInfo {
+  registered_on: Timestamp;
+}
+
+/**
+ * The AccountExternalIdInfo entity contains information for an external id of
+ * an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-external-id-info
+ */
+export interface AccountExternalIdInfo {
+  identity: string;
+  email?: string;
+  trusted?: boolean;
+  can_delete?: boolean;
+}
+
+/**
+ * The GroupAuditEventInfo entity contains information about an auditevent of a group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupAuditEventInfo {
+  member: string;
+  type: string;
+  user: string;
+  date: string;
+}
+
+/**
+ * The GroupBaseInfo entity contains base information about the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#group-base-info
+ */
+export interface GroupBaseInfo {
+  id: GroupId;
+  name: GroupName;
+}
+
+/**
+ * The GroupInfo entity contains information about a group. This can be a
+ * Gerrit internal group, or an external group that is known to Gerrit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info
+ */
+export interface GroupInfo {
+  id: GroupId;
+  name?: GroupName;
+  url?: string;
+  options?: GroupOptionsInfo;
+  description?: string;
+  group_id?: string;
+  owner?: string;
+  owner_id?: string;
+  created_on?: string;
+  _more_groups?: boolean;
+  members?: AccountInfo[];
+  includes?: GroupInfo[];
+}
+
+export type GroupNameToGroupInfoMap = {[groupName: string]: GroupInfo};
+
+/**
+ * The 'GroupInput' entity contains information for the creation of a new
+ * internal group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-input
+ */
+export interface GroupInput {
+  name?: GroupName;
+  uuid?: string;
+  description?: string;
+  visible_to_all?: string;
+  owner_id?: string;
+  members?: string[];
+}
+
+/**
+ * Options of the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupOptionsInfo {
+  visible_to_all: boolean;
+}
+
+/**
+ * New options for a group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupOptionsInput {
+  visible_to_all: boolean;
+}
+
+/**
+ * The GroupsInput entity contains information about groups that should be
+ * included into a group or that should be deleted from a group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupsInput {
+  _one_group?: string;
+  groups?: string[];
+}
+
+/**
+ * The MembersInput entity contains information about accounts that should be
+ * added as members to a group or that should be deleted from the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface MembersInput {
+  _one_member?: string;
+  members?: string[];
+}
+
+/**
+ * The ActionInfo entity describes a REST API call the client canmake to
+ * manipulate a resource. These are frequently implemented by plugins and may
+ * be discovered at runtime.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
+ */
+export interface ActionInfo {
+  method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
+  label?: string; // Short title to display to a user describing the action
+  title?: string; // Longer text to display describing the action
+  enabled?: boolean; // not set if false
+}
+
+export interface ActionNameToActionInfoMap {
+  [actionType: string]: ActionInfo | undefined;
+  // List of actions explicitly used in code:
+  wip?: ActionInfo;
+  publishEdit?: ActionInfo;
+  rebaseEdit?: ActionInfo;
+  deleteEdit?: ActionInfo;
+  edit?: ActionInfo;
+  stopEdit?: ActionInfo;
+  download?: ActionInfo;
+  rebase?: ActionInfo;
+  cherrypick?: ActionInfo;
+  move?: ActionInfo;
+  revert?: ActionInfo;
+  revert_submission?: ActionInfo;
+  abandon?: ActionInfo;
+  submit?: ActionInfo;
+  topic?: ActionInfo;
+  hashtags?: ActionInfo;
+  assignee?: ActionInfo;
+  ready?: ActionInfo;
+}
+
+/**
+ * The Requirement entity contains information about a requirement relative to
+ * a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#requirement
+ */
+export interface Requirement {
+  status: RequirementStatus;
+  fallbackText: string; // A human readable reason
+  type: RequirementType;
+}
+
+/**
+ * The ReviewerUpdateInfo entity contains information about updates to change’s
+ * reviewers set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-update-info
+ */
+export interface ReviewerUpdateInfo {
+  updated: Timestamp;
+  updated_by: AccountInfo;
+  reviewer: AccountInfo;
+  state: ReviewerState;
+}
+
+/**
+ * The ChangeMessageInfo entity contains information about a messageattached
+ * to a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-message-info
+ */
+export interface ChangeMessageInfo {
+  id: ChangeMessageId;
+  author?: AccountInfo;
+  reviewer?: AccountInfo;
+  updated_by?: AccountInfo;
+  real_author?: AccountInfo;
+  date: Timestamp;
+  message: string;
+  tag?: ReviewInputTag;
+  _revision_number?: PatchSetNum;
+}
+
+/**
+ * The RevisionInfo entity contains information about a patch set.Not all
+ * fields are returned by default.  Additional fields can be obtained by
+ * adding o parameters as described in Query Changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-info
+ * basePatchNum is present in case RevisionInfo is of type 'edit'
+ */
+export interface RevisionInfo {
+  kind: RevisionKind;
+  _number: PatchSetNum;
+  created: Timestamp;
+  uploader: AccountInfo;
+  ref: GitRef;
+  fetch?: {[protocol: string]: FetchInfo};
+  commit?: CommitInfo;
+  files?: {[filename: string]: FileInfo};
+  actions?: ActionNameToActionInfoMap;
+  reviewed?: boolean;
+  commit_with_footers?: boolean;
+  push_certificate?: PushCertificateInfo;
+  description?: string;
+  basePatchNum?: PatchSetNum;
+}
+
+/**
+ * The TrackingIdInfo entity describes a reference to an external tracking
+ * system.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#tracking-id-info
+ */
+export interface TrackingIdInfo {
+  system: string;
+  id: TrackingId;
+}
+
+/**
+ * The ProblemInfo entity contains a description of a potential consistency
+ * problem with a change. These are not related to the code review process,
+ * but rather indicate some inconsistency in Gerrit’s database or repository
+ * metadata related to the enclosing change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#problem-info
+ */
+export interface ProblemInfo {
+  message: string;
+  status?: ProblemInfoStatus; // Only set if a fix was attempted
+  outcome?: string;
+}
+
+/**
+ * The AttentionSetInfo entity contains details of users that are in the attention set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-info
+ */
+export interface AttentionSetInfo {
+  account: AccountInfo;
+  last_update?: Timestamp;
+  reason?: string;
+}
+
+/**
+ * The ApprovalInfo entity contains information about an approval from auser
+ * for a label on a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#approval-info
+ */
+export interface ApprovalInfo extends AccountInfo {
+  value?: number;
+  permitted_voting_range?: VotingRangeInfo;
+  date?: Timestamp;
+  tag?: ReviewInputTag;
+  post_submit?: boolean; // not set if false
+}
+
+/**
+ * The AvartarInfo entity contains information about an avatar image ofan
+ * account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#avatar-info
+ */
+export interface AvatarInfo {
+  url: string;
+  height: number;
+  width: number;
+}
+
+/**
+ * The FetchInfo entity contains information about how to fetch a patchset via
+ * a certain protocol.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fetch-info
+ */
+export interface FetchInfo {
+  url: string;
+  ref: string;
+  commands?: {[commandName: string]: string};
+}
+
+/**
+ * The CommitInfo entity contains information about a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export interface CommitInfo {
+  commit?: CommitId;
+  parents: ParentCommitInfo[];
+  author: GitPersonInfo;
+  committer: GitPersonInfo;
+  subject: string;
+  message: string;
+  web_links?: WebLinkInfo[];
+}
+
+export interface CommitInfoWithRequiredCommit extends CommitInfo {
+  commit: CommitId;
+}
+
+/**
+ * Standalone Commit Info.
+ * Same as CommitInfo, except `commit` is required
+ * as it is only optional when used inside of the RevisionInfo.
+ */
+export interface StandaloneCommitInfo extends CommitInfo {
+  commit: CommitId;
+}
+
+/**
+ * The parent commits of this commit as a list of CommitInfo entities.
+ * In each parent only the commit and subject fields are populated.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export interface ParentCommitInfo {
+  commit: CommitId;
+  subject: string;
+}
+
+/**
+ * The FileInfo entity contains information about a file in a patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-info
+ */
+export interface FileInfo {
+  status?: FileInfoStatus;
+  binary?: boolean; // not set if false
+  old_path?: string;
+  lines_inserted?: number;
+  lines_deleted?: number;
+  size_delta: number; // in bytes
+  size: number; // in bytes
+}
+
+/**
+ * The PushCertificateInfo entity contains information about a pushcertificate
+ * provided when the user pushed for review with git push
+ * --signed HEAD:refs/for/<branch>. Only used when signed push is
+ * enabled on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#push-certificate-info
+ */
+export interface PushCertificateInfo {
+  certificate: string;
+  key: GpgKeyInfo;
+}
+
+/**
+ * The GpgKeyInfo entity contains information about a GPG public key.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-key-info
+ */
+export interface GpgKeyInfo {
+  id?: GpgKeyId;
+  fingerprint?: GpgKeyFingerprint;
+  user_ids?: OpenPgpUserIds[];
+  key?: string; // ASCII armored public key material
+  status?: GpgKeyInfoStatus;
+  problems?: string[];
+}
+
+/**
+ * The GpgKeysInput entity contains information for adding/deleting GPG keys.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-keys-input
+ */
+export interface GpgKeysInput {
+  add?: string[];
+  delete?: string[];
+}
+
+/**
+ * The GitPersonInfo entity contains information about theauthor/committer of
+ * a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#git-person-info
+ */
+export interface GitPersonInfo {
+  name: string;
+  email: string;
+  date: Timestamp;
+  tz: TimezoneOffset;
+}
+
+/**
+ * The WebLinkInfo entity describes a link to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
+ */
+export interface WebLinkInfo {
+  name: string;
+  url: string;
+  image_url: string;
+}
+
+/**
+ * The VotingRangeInfo entity describes the continuous voting range from minto
+ * max values.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#voting-range-info
+ */
+export interface VotingRangeInfo {
+  min: number;
+  max: number;
+}
+
+/**
+ * The AccountsConfigInfo entity contains information about Gerrit configuration
+ * from the accounts section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#accounts-config-info
+ */
+export interface AccountsConfigInfo {
+  visibility: string;
+  default_display_name: DefaultDisplayNameConfig;
+}
+
+/**
+ * The AuthInfo entity contains information about the authentication
+ * configuration of the Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export interface AuthInfo {
+  auth_type: AuthType; // docs incorrectly names it 'type'
+  use_contributor_agreements?: boolean;
+  contributor_agreements?: ContributorAgreementInfo;
+  editable_account_fields: EditableAccountField[];
+  login_url?: string;
+  login_text?: string;
+  switch_account_url?: string;
+  register_url?: string;
+  register_text?: string;
+  edit_full_name_url?: string;
+  http_password_url?: string;
+  git_basic_auth_policy?: string;
+}
+
+/**
+ * The CacheInfo entity contains information about a cache.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CacheInfo {
+  name: string;
+  type: string;
+  entries: EntriesInfo;
+  average_get?: string;
+  hit_ratio: HitRatioInfo;
+}
+
+/**
+ * The CacheOperationInput entity contains information about an operation that
+ * should be executed on caches.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CacheOperationInput {
+  operation: string;
+  caches?: string[];
+}
+
+/**
+ * The CapabilityInfo entity contains information about a capability.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#capability-info
+ */
+export interface CapabilityInfo {
+  id: string;
+  name: string;
+}
+
+export type CapabilityInfoMap = {[id: string]: CapabilityInfo};
+
+/**
+ * The ChangeConfigInfo entity contains information about Gerrit configuration
+ * from the change section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-config-info
+ */
+export interface ChangeConfigInfo {
+  allow_blame?: boolean;
+  large_change: number;
+  reply_label: string;
+  reply_tooltip: string;
+  update_delay: number;
+  submit_whole_topic?: boolean;
+  disable_private_changes?: boolean;
+  mergeability_computation_behavior: MergeabilityComputationBehavior;
+  enable_attention_set: boolean;
+  enable_assignee: boolean;
+}
+
+/**
+ * The ChangeIndexConfigInfo entity contains information about Gerrit
+ * configuration from the index.change section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-index-config-info
+ */
+export interface ChangeIndexConfigInfo {
+  index_mergeable?: boolean;
+}
+
+/**
+ * The CheckAccountExternalIdsResultInfo entity contains the result of running
+ * the account external ID consistency check.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CheckAccountExternalIdsResultInfo {
+  problems: string;
+}
+
+/**
+ * The CheckAccountsResultInfo entity contains the result of running the account
+ * consistency check.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CheckAccountsResultInfo {
+  problems: string;
+}
+
+/**
+ * The CheckGroupsResultInfo entity contains the result of running the group
+ * consistency check.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CheckGroupsResultInfo {
+  problems: string;
+}
+
+/**
+ * The ConsistencyCheckInfo entity contains the results of running consistency
+ * checks.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConsistencyCheckInfo {
+  check_accounts_result?: CheckAccountsResultInfo;
+  check_account_external_ids_result?: CheckAccountExternalIdsResultInfo;
+  check_groups_result?: CheckGroupsResultInfo;
+}
+
+/**
+ * The ConsistencyCheckInput entity contains information about which consistency
+ * checks should be run.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConsistencyCheckInput {
+  check_accounts?: string;
+  check_account_external_ids?: string;
+  check_groups?: string;
+}
+
+/**
+ * The ConsistencyProblemInfo entity contains information about a consistency
+ * problem.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConsistencyProblemInfo {
+  status: string;
+  message: string;
+}
+
+/**
+ * The entity describes the result of a reload of gerrit.config.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConfigUpdateInfo {
+  applied: string;
+  rejected: string;
+}
+
+/**
+ * The entity describes an updated config value.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConfigUpdateEntryInfo {
+  config_key: string;
+  old_value: string;
+  new_value: string;
+}
+
+export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
+
+/**
+ * The DownloadInfo entity contains information about supported download
+ * options.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#download-info
+ */
+export interface DownloadInfo {
+  schemes: SchemesInfoMap;
+  archives: string[];
+}
+
+export type CloneCommandMap = {[name: string]: string};
+/**
+ * The DownloadSchemeInfo entity contains information about a supported download
+ * scheme and its commands.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface DownloadSchemeInfo {
+  url: string;
+  is_auth_required: boolean;
+  is_auth_supported: boolean;
+  commands: string;
+  clone_commands: CloneCommandMap;
+}
+
+/**
+ * The EmailConfirmationInput entity contains information for confirming an
+ * email address.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface EmailConfirmationInput {
+  token: string;
+}
+
+/**
+ * The EntriesInfo entity contains information about the entries in acache.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface EntriesInfo {
+  mem?: string;
+  disk?: string;
+  space?: string;
+}
+
+/**
+ * The GerritInfo entity contains information about Gerrit configuration from
+ * the gerrit section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#gerrit-info
+ */
+export interface GerritInfo {
+  all_projects: string; // Doc contains incorrect name
+  all_users: string; // Doc contains incorrect name
+  doc_search: boolean;
+  doc_url?: string;
+  edit_gpg_keys?: boolean;
+  report_bug_url?: string;
+  // The following property is missed in doc
+  primary_weblink_name?: string;
+}
+
+/**
+ * The IndexConfigInfo entity contains information about Gerrit configuration
+ * from the index section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#index-config-info
+ */
+export interface IndexConfigInfo {
+  change: ChangeIndexConfigInfo;
+}
+
+/**
+ * The HitRatioInfo entity contains information about the hit ratio of a cache.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface HitRatioInfo {
+  mem: string;
+  disk?: string;
+}
+
+/**
+ * The IndexChangesInput contains a list of numerical changes IDs to index.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface IndexChangesInput {
+  changes: string;
+}
+
+/**
+ * The JvmSummaryInfo entity contains information about the JVM.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface JvmSummaryInfo {
+  vm_vendor: string;
+  vm_name: string;
+  vm_version: string;
+  os_name: string;
+  os_version: string;
+  os_arch: string;
+  user: string;
+  host?: string;
+  current_working_directory: string;
+  site: string;
+}
+
+/**
+ * The MemSummaryInfo entity contains information about the current memory
+ * usage.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface MemSummaryInfo {
+  total: string;
+  used: string;
+  free: string;
+  buffers: string;
+  max: string;
+  open_files?: string;
+}
+
+/**
+ * The PluginConfigInfo entity contains information about Gerrit extensions by
+ * plugins.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#plugin-config-info
+ */
+export interface PluginConfigInfo {
+  has_avatars: boolean;
+  // The following 2 properies exists in Java class, but don't mention in docs
+  js_resource_paths: string[];
+  html_resource_paths: string[];
+}
+
+/**
+ * The ReceiveInfo entity contains information about the configuration of
+ * git-receive-pack behavior on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#receive-info
+ */
+export interface ReceiveInfo {
+  enable_signed_push?: string;
+}
+
+/**
+ * The ServerInfo entity contains information about the configuration of the
+ * Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
+ */
+export interface ServerInfo {
+  accounts: AccountsConfigInfo;
+  auth: AuthInfo;
+  change: ChangeConfigInfo;
+  download: DownloadInfo;
+  gerrit: GerritInfo;
+  // docs mentions index property, but it doesn't exists in Java class
+  // index: IndexConfigInfo;
+  note_db_enabled?: boolean;
+  plugin: PluginConfigInfo;
+  receive?: ReceiveInfo;
+  sshd?: SshdInfo;
+  suggest: SuggestInfo;
+  user: UserConfigInfo;
+  default_theme?: string;
+}
+
+/**
+ * The SshdInfo entity contains information about Gerrit configuration from the sshd section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#sshd-info
+ * This entity doesn’t contain any data, but the presence of this (empty) entity
+ * in the ServerInfo entity means that SSHD is enabled on the server.
+ */
+export type SshdInfo = {};
+
+/**
+ * The SuggestInfo entity contains information about Gerritconfiguration from
+ * the suggest section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
+ */
+export interface SuggestInfo {
+  from: number;
+}
+
+/**
+ * The SummaryInfo entity contains information about the current state of the
+ * server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface SummaryInfo {
+  task_summary: TaskSummaryInfo;
+  mem_summary: MemSummaryInfo;
+  thread_summary: ThreadSummaryInfo;
+  jvm_summary?: JvmSummaryInfo;
+}
+
+/**
+ * The TaskInfo entity contains information about a task in a background work
+ * queue.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface TaskInfo {
+  id: string;
+  state: string;
+  start_time: string;
+  delay: string;
+  command: string;
+  remote_name?: string;
+  project?: string;
+}
+
+/**
+ * The TaskSummaryInfo entity contains information about the current tasks.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface TaskSummaryInfo {
+  total?: string;
+  running?: string;
+  ready?: string;
+  sleeping?: string;
+}
+
+/**
+ * The ThreadSummaryInfo entity contains information about the current threads.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ThreadSummaryInfo {
+  cpus: string;
+  threads: string;
+  counts: string;
+}
+
+/**
+ * The TopMenuEntryInfo entity contains information about a top menu entry.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-entry-info
+ */
+export interface TopMenuEntryInfo {
+  name: string;
+  items: TopMenuItemInfo[];
+}
+
+/**
+ * The TopMenuItemInfo entity contains information about a menu item ina top
+ * menu entry.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-item-info
+ */
+export interface TopMenuItemInfo {
+  url: string;
+  name: string;
+  target: string;
+  id?: string;
+}
+
+/**
+ * The UserConfigInfo entity contains information about Gerrit configuration
+ * from the user section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#user-config-info
+ */
+export interface UserConfigInfo {
+  anonymous_coward_name: string;
+}
+
+/*
+ * The CommentInfo entity contains information about an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export interface CommentInfo {
+  // TODO(TS): Make this required.
+  patch_set?: PatchSetNum;
+  id: UrlEncodedCommentId;
+  path?: string;
+  side?: CommentSide;
+  parent?: number;
+  line?: number;
+  range?: CommentRange;
+  in_reply_to?: UrlEncodedCommentId;
+  message?: string;
+  updated: Timestamp;
+  author?: AccountInfo;
+  tag?: string;
+  unresolved?: boolean;
+  change_message_id?: string;
+  commit_id?: string;
+}
+
+export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
+
+/**
+ * The CommentRange entity describes the range of an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ */
+export interface CommentRange {
+  start_line: number;
+  start_character: number;
+  end_line: number;
+  end_character: number;
+}
+
+/**
+ * The ProjectInfo entity contains information about a project
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info
+ */
+export interface ProjectInfo {
+  id: UrlEncodedRepoName;
+  // name is not set if returned in a map where the project name is used as
+  // map key
+  name?: RepoName;
+  // ?-<n> if the parent project is not visible (<n> is a number which
+  // is increased for each non-visible project).
+  parent?: RepoName;
+  description?: string;
+  state?: ProjectState;
+  branches?: {[branchName: string]: CommitId};
+  // labels is filled for Create Project and Get Project calls.
+  labels?: LabelNameToLabelTypeInfoMap;
+  // Links to the project in external sites
+  web_links?: WebLinkInfo[];
+}
+
+export interface ProjectInfoWithName extends ProjectInfo {
+  name: RepoName;
+}
+
+export type NameToProjectInfoMap = {[projectName: string]: ProjectInfo};
+export type LabelNameToLabelTypeInfoMap = {[labelName: string]: LabelTypeInfo};
+
+/**
+ * The LabelTypeInfo entity contains metadata about the labels that a project
+ * has.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#label-type-info
+ */
+export interface LabelTypeInfo {
+  values: LabelTypeInfoValues;
+  default_value: number;
+}
+
+export type LabelTypeInfoValues = {[value: string]: string};
+
+/**
+ * The DiffContent entity contains information about the content differences in a file.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
+ */
+export interface DiffContent {
+  a?: string[];
+  b?: string[];
+  ab?: string[];
+  // The inner array is always of length two. The first entry is the 'skip'
+  // length. The second entry is the 'edit' length.
+  edit_a?: number[][];
+  edit_b?: number[][];
+  due_to_rebase?: boolean;
+  due_to_move?: boolean;
+  skip?: number;
+  common?: string;
+  keyLocation?: boolean;
+}
+
+/**
+ * The DiffFileMetaInfo entity contains meta information about a file diff.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
+ */
+export interface DiffFileMetaInfo {
+  name: string;
+  content_type: string;
+  lines: string;
+  web_links?: WebLinkInfo[];
+  language?: string;
+}
+
+/**
+ * The DiffInfo entity contains information about the diff of a file in a revision.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-info
+ */
+export interface DiffInfo {
+  meta_a: DiffFileMetaInfo;
+  meta_b: DiffFileMetaInfo;
+  change_type: string;
+  intraline_status: string;
+  diff_header: string[];
+  content: DiffContent[];
+  web_links?: DiffWebLinkInfo[];
+  binary: boolean;
+}
+
+export type FilePathToDiffInfoMap = {[path: string]: DiffInfo};
+
+/**
+ * The DiffWebLinkInfo entity describes a link on a diff screen to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
+ */
+export interface DiffWebLinkInfo {
+  name: string;
+  url: string;
+  image_url: string;
+  show_on_side_by_side_diff_view: string;
+  show_on_unified_diff_view: string;
+}
+
+/**
+ * The DiffPreferencesInfo entity contains information about the diff preferences of a user.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-info
+ */
+export interface DiffPreferencesInfo {
+  context: number;
+  expand_all_comments?: boolean;
+  ignore_whitespace: IgnoreWhitespaceType;
+  intraline_difference?: boolean;
+  line_length: number;
+  cursor_blink_rate: number;
+  manual_review?: boolean;
+  retain_header?: boolean;
+  show_line_endings?: boolean;
+  show_tabs?: boolean;
+  show_whitespace_errors?: boolean;
+  skip_deleted?: boolean;
+  skip_uncommented?: boolean;
+  syntax_highlighting?: boolean;
+  hide_top_menu?: boolean;
+  auto_hide_diff_table_header?: boolean;
+  hide_line_numbers?: boolean;
+  tab_size: number;
+  font_size: number;
+  hide_empty_pane?: boolean;
+  match_brackets?: boolean;
+  line_wrapping?: boolean;
+  // TODO(TS): show_file_comment_button exists in JS code, but doesn't exist in the doc.
+  // Either remove or update doc
+  show_file_comment_button?: boolean;
+  // TODO(TS): theme exists in JS code, but doesn't exist in the doc.
+  // Either remove or update doc
+  theme?: string;
+}
+export type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
+
+/**
+ * The RangeInfo entity stores the coordinates of a range.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#range-info
+ */
+export interface RangeInfo {
+  start: number;
+  end: number;
+}
+
+/**
+ * The BlameInfo entity stores the commit metadata with the row coordinates where it applies.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#blame-info
+ */
+export interface BlameInfo {
+  author: string;
+  id: string;
+  time: number;
+  commit_msg: string;
+  ranges: RangeInfo[];
+}
+
+/**
+ * Images are retrieved by using the file content API and the body is just the
+ * HTML response.
+ * TODO(TS): where is the source of this type ? I don't find it in doc
+ */
+export interface ImageInfo {
+  body: string;
+  type: string;
+  _name?: string;
+  _expectedType?: string;
+  _width?: number;
+  _height?: number;
+}
+
+/**
+ * A boolean value that can also be inherited.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
+ */
+export interface InheritedBooleanInfo {
+  value: boolean;
+  configured_value: InheritedBooleanInfoConfiguredValue;
+  inherited_value?: boolean;
+}
+
+/**
+ * The MaxObjectSizeLimitInfo entity contains information about the max object
+ * size limit of a project.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
+ */
+export interface MaxObjectSizeLimitInfo {
+  value?: string;
+  configured_value?: string;
+  summary?: string;
+}
+
+/**
+ * Information about the default submittype of a project, taking into account
+ * project inheritance.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
+ */
+export interface SubmitTypeInfo {
+  value: Exclude<SubmitType, SubmitType.INHERIT>;
+  configured_value: SubmitType;
+  inherited_value: Exclude<SubmitType, SubmitType.INHERIT>;
+}
+
+/**
+ * The CommentLinkInfo entity describes acommentlink.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#commentlink-info
+ */
+export interface CommentLinkInfo {
+  match: string;
+  link?: string;
+  enabled?: boolean;
+  html?: string;
+}
+
+/**
+ * The ConfigParameterInfo entity describes a project configurationparameter.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
+ */
+export interface ConfigParameterInfoBase {
+  display_name?: string;
+  description?: string;
+  warning?: string;
+  type: ConfigParameterInfoType;
+  value?: string;
+  values?: string[];
+  editable?: boolean;
+  permitted_values?: string[];
+  inheritable?: boolean;
+  configured_value?: string;
+  inherited_value?: string;
+}
+
+export interface ConfigArrayParameterInfo extends ConfigParameterInfoBase {
+  type: ConfigParameterInfoType.ARRAY;
+  values: string[];
+}
+
+export interface ConfigListParameterInfo extends ConfigParameterInfoBase {
+  type: ConfigParameterInfoType.LIST;
+  permitted_values?: string[];
+}
+
+export type ConfigParameterInfo =
+  | ConfigParameterInfoBase
+  | ConfigArrayParameterInfo
+  | ConfigListParameterInfo;
+
+export interface CommentLinks {
+  [name: string]: CommentLinkInfo;
+}
+
+/**
+ * The ConfigInfo entity contains information about the effective
+ * project configuration.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+ */
+export interface ConfigInfo {
+  description?: string;
+  use_contributor_agreements?: InheritedBooleanInfo;
+  use_content_merge?: InheritedBooleanInfo;
+  use_signed_off_by?: InheritedBooleanInfo;
+  create_new_change_for_all_not_in_target?: InheritedBooleanInfo;
+  require_change_id?: InheritedBooleanInfo;
+  enable_signed_push?: InheritedBooleanInfo;
+  require_signed_push?: InheritedBooleanInfo;
+  reject_implicit_merges?: InheritedBooleanInfo;
+  private_by_default: InheritedBooleanInfo;
+  work_in_progress_by_default: InheritedBooleanInfo;
+  max_object_size_limit: MaxObjectSizeLimitInfo;
+  default_submit_type: SubmitTypeInfo;
+  submit_type: SubmitType;
+  match_author_to_committer_date?: InheritedBooleanInfo;
+  state?: ProjectState;
+  commentlinks: CommentLinks;
+  plugin_config?: PluginNameToPluginParametersMap;
+  actions?: {[viewName: string]: ActionInfo};
+  reject_empty_commit?: InheritedBooleanInfo;
+}
+
+/**
+ * The ProjectAccessInfo entity contains information about the access rights for
+ * a project.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
+ */
+export interface ProjectAccessInfo {
+  revision: string; // The revision of the refs/meta/config branch from which the access rights were loaded
+  inherits_from?: ProjectInfo; // not set for the All-Project project
+  local: LocalAccessSectionInfo;
+  is_owner?: boolean;
+  owner_of: GitRef[];
+  can_upload?: boolean;
+  can_add?: boolean;
+  can_add_tags?: boolean;
+  config_visible?: boolean;
+  groups: ProjectAccessGroups;
+  config_web_links: string[];
+}
+
+export type ProjectAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
+export type LocalAccessSectionInfo = {[ref: string]: AccessSectionInfo};
+export type ProjectAccessGroups = {[uuid: string]: GroupInfo};
+
+/**
+ * The AccessSectionInfo describes the access rights that are assigned on a ref.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#access-section-info
+ */
+export interface AccessSectionInfo {
+  permissions: AccessPermissionsMap;
+}
+
+export type AccessPermissionsMap = {[permissionName: string]: PermissionInfo};
+
+/**
+ * The PermissionInfo entity contains information about an assigned permission
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#permission-info
+ */
+export interface PermissionInfo {
+  label?: string; // The name of the label. Not set if it’s not a label permission.
+  exclusive?: boolean;
+  rules: PermissionInfoRules;
+}
+
+export type PermissionInfoRules = {[groupUUID: string]: PermissionRuleInfo};
+
+/**
+ * The PermissionRuleInfo entity contains information about a permission rule that is assigned to group
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#permission-info
+ */
+export interface PermissionRuleInfo {
+  action: PermissionAction;
+  force?: boolean;
+  min?: number; // not set if range is empty (from 0 to 0) or not set
+  max?: number; // not set if range is empty (from 0 to 0) or not set
+}
+
+/**
+ * The DashboardInfo entity contains information about a project dashboard
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#dashboard-info
+ */
+export interface DashboardInfo {
+  id: DashboardId;
+  project: RepoName;
+  defining_project: RepoName;
+  ref: string; // The name of the ref in which the dashboard is defined, without the refs/meta/dashboards/ prefix
+  path: string;
+  description?: string;
+  foreach?: string;
+  url: string;
+  is_default?: boolean;
+  title?: string;
+  sections: DashboardSectionInfo[];
+}
+
+/**
+ * The DashboardSectionInfo entity contains information about a section in a dashboard.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#dashboard-section-info
+ */
+export interface DashboardSectionInfo {
+  name: string;
+  query: string;
+}
+
+/**
+ * The ConfigInput entity describes a new project configuration
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
+ */
+export interface ConfigInput {
+  description?: string;
+  use_contributor_agreements?: InheritedBooleanInfoConfiguredValue;
+  use_content_merge?: InheritedBooleanInfoConfiguredValue;
+  use_signed_off_by?: InheritedBooleanInfoConfiguredValue;
+  create_new_change_for_all_not_in_target?: InheritedBooleanInfoConfiguredValue;
+  require_change_id?: InheritedBooleanInfoConfiguredValue;
+  enable_signed_push?: InheritedBooleanInfoConfiguredValue;
+  require_signed_push?: InheritedBooleanInfoConfiguredValue;
+  private_by_default?: InheritedBooleanInfoConfiguredValue;
+  work_in_progress_by_default?: InheritedBooleanInfoConfiguredValue;
+  enable_reviewer_by_email?: InheritedBooleanInfoConfiguredValue;
+  match_author_to_committer_date?: InheritedBooleanInfoConfiguredValue;
+  reject_implicit_merges?: InheritedBooleanInfoConfiguredValue;
+  reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
+  max_object_size_limit?: MaxObjectSizeLimitInfo;
+  submit_type?: SubmitType;
+  state?: ProjectState;
+  plugin_config_values?: PluginNameToPluginParametersMap;
+  commentlinks?: ConfigInfoCommentLinks;
+}
+/**
+ * Plugin configuration values as map which maps the plugin name to a map of parameter names to values
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
+ */
+export type PluginNameToPluginParametersMap = {
+  [pluginName: string]: PluginParameterToConfigParameterInfoMap;
+};
+
+export type PluginParameterToConfigParameterInfoMap = {
+  [parameterName: string]: ConfigParameterInfo;
+};
+
+export type ConfigInfoCommentLinks = {
+  [commentLinkName: string]: CommentLinkInfo;
+};
+
+/**
+ * The ProjectInput entity contains information for the creation of a new project
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-input
+ */
+export interface ProjectInput {
+  name?: RepoName;
+  parent?: RepoName;
+  description?: string;
+  permissions_only?: boolean;
+  create_empty_commit?: boolean;
+  submit_type?: SubmitType;
+  branches?: BranchName[];
+  owners?: GroupId[];
+  use_contributor_agreements?: InheritedBooleanInfoConfiguredValue;
+  use_signed_off_by?: InheritedBooleanInfoConfiguredValue;
+  create_new_change_for_all_not_in_target?: InheritedBooleanInfoConfiguredValue;
+  use_content_merge?: InheritedBooleanInfoConfiguredValue;
+  require_change_id?: InheritedBooleanInfoConfiguredValue;
+  enable_signed_push?: InheritedBooleanInfoConfiguredValue;
+  require_signed_push?: InheritedBooleanInfoConfiguredValue;
+  max_object_size_limit?: string;
+  reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
+}
+
+/**
+ * The BranchInfo entity contains information about a branch
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-info
+ */
+export interface BranchInfo {
+  ref: GitRef;
+  revision: string;
+  can_delete?: boolean;
+  web_links?: WebLinkInfo[];
+}
+
+/**
+ * The ProjectAccessInput describes changes that should be applied to a project access config
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-access-input
+ */
+export interface ProjectAccessInput {
+  remove?: RefToProjectAccessInfoMap;
+  add?: RefToProjectAccessInfoMap;
+  message?: string;
+  parent?: string;
+}
+
+export type RefToProjectAccessInfoMap = {[refName: string]: ProjectAccessInfo};
+
+/**
+ * Represent a file in a base64 encoding
+ */
+export interface Base64File {
+  body: string;
+  type: string | null;
+}
+
+/**
+ * Represent a file in a base64 encoding; GrRestApiInterface returns it from some
+ * methods
+ */
+export interface Base64FileContent {
+  content: string | null;
+  type: string | null;
+  ok: true;
+}
+
+/**
+ * The WatchedProjectsInfo entity contains information about a project watch for a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#project-watch-info
+ */
+export interface ProjectWatchInfo {
+  project: RepoName;
+  filter?: string;
+  notify_new_changes?: boolean;
+  notify_new_patch_sets?: boolean;
+  notify_all_comments?: boolean;
+  notify_submitted_changes?: boolean;
+  notify_abandoned_changes?: boolean;
+}
+/**
+ * The DeleteDraftCommentsInput entity contains information specifying a set of draft comments that should be deleted
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#delete-draft-comments-input
+ */
+export interface DeleteDraftCommentsInput {
+  query: string;
+}
+
+/**
+ * The AssigneeInput entity contains the identity of the user to be set as assignee
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#assignee-input
+ */
+export interface AssigneeInput {
+  assignee: AccountId;
+}
+
+/**
+ * The SshKeyInfo entity contains information about an SSH key of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#ssh-key-info
+ */
+export interface SshKeyInfo {
+  seq: number;
+  ssh_public_key: string;
+  encoded_key: string;
+  algorithm: string;
+  comment?: string;
+  valid: boolean;
+}
+
+/**
+ * The HashtagsInput entity contains information about hashtags to add to, and/or remove from, a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#hashtags-input
+ */
+export interface HashtagsInput {
+  add?: Hashtag[];
+  remove?: Hashtag[];
+}
+
+/**
+ * Defines a patch ranges. Used as input for gr-rest-api-interface methods,
+ * doesn't exist in Rest API
+ */
+export interface PatchRange {
+  patchNum: PatchSetNum;
+  basePatchNum: PatchSetNum;
+}
+
+/**
+ * The CommentInput entity contains information for creating an inline comment
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-input
+ */
+export interface CommentInput {
+  id?: UrlEncodedCommentId;
+  path?: string;
+  side?: CommentSide;
+  line?: number;
+  range?: CommentRange;
+  in_reply_to?: UrlEncodedCommentId;
+  updated?: Timestamp;
+  message?: string;
+  tag?: string;
+  unresolved?: boolean;
+}
+
+/**
+ * The EditPreferencesInfo entity contains information about the edit preferences of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#edit-preferences-info
+ */
+export interface EditPreferencesInfo {
+  tab_size: number;
+  line_length: number;
+  indent_unit: number;
+  cursor_blink_rate: number;
+  hide_top_menu?: boolean;
+  show_tabs?: boolean;
+  show_whitespace_errors?: boolean;
+  syntax_highlighting?: boolean;
+  hide_line_numbers?: boolean;
+  match_brackets?: boolean;
+  line_wrapping?: boolean;
+  indent_with_tabs?: boolean;
+  auto_close_brackets?: boolean;
+  show_base?: boolean;
+  // TODO(TS): the following proeprties doesn't exist in RestAPI doc
+  key_map_type?: string;
+  theme?: string;
+}
+
+/**
+ * The PreferencesInput entity contains information for setting the user preferences. Fields which are not set will not be updated
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ *
+ * Note: the doc missed several properties. Java code uses the same class (GeneralPreferencesInfo)
+ * both for input data and for response data.
+ */
+export type PreferencesInput = Partial<PreferencesInfo>;
+
+/**
+ * The DiffPreferencesInput entity contains information for setting the diff preferences of a user. Fields which are not set will not be updated
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
+ */
+export interface DiffPreferenceInput {
+  context?: number;
+  expand_all_comments?: boolean;
+  ignore_whitespace: IgnoreWhitespaceType;
+  intraline_difference?: boolean;
+  line_length?: number;
+  manual_review?: boolean;
+  retain_header?: boolean;
+  show_line_endings?: boolean;
+  show_tabs?: boolean;
+  show_whitespace_errors?: boolean;
+  skip_deleted?: boolean;
+  skip_uncommented?: boolean;
+  syntax_highlighting?: boolean;
+  hide_top_menu?: boolean;
+  auto_hide_diff_table_header?: boolean;
+  hide_line_numbers?: boolean;
+  tab_size?: number;
+  font_size?: number;
+  line_wrapping?: boolean;
+  indent_with_tabs?: boolean;
+}
+
+/**
+ * The EmailInfo entity contains information about an email address of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#email-info
+ */
+export interface EmailInfo {
+  email: string;
+  preferred?: boolean;
+  pending_confirmation?: boolean;
+}
+
+/**
+ * The CapabilityInfo entity contains information about the global capabilities of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#capability-info
+ */
+export interface AccountCapabilityInfo {
+  accessDatabase?: boolean;
+  administrateServer?: boolean;
+  createAccount?: boolean;
+  createGroup?: boolean;
+  createProject?: boolean;
+  emailReviewers?: boolean;
+  flushCaches?: boolean;
+  killTask?: boolean;
+  maintainServer?: boolean;
+  priority: UserPriority;
+  queryLimit: QueryLimitInfo;
+  runAs?: boolean;
+  runGC?: boolean;
+  streamEvents?: boolean;
+  viewAllAccounts?: boolean;
+  viewCaches?: boolean;
+  viewConnections?: boolean;
+  viewPlugins?: boolean;
+  viewQueue?: boolean;
+}
+
+/**
+ * The QueryLimitInfo entity contains information about the Query Limit of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-limit-info
+ */
+export interface QueryLimitInfo {
+  min: number;
+  max: number;
+}
+
+/**
+ * The PreferencesInfo entity contains information about a user’s preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-info
+ */
+export interface PreferencesInfo {
+  changes_per_page: 10 | 25 | 50 | 100;
+  theme: AppTheme;
+  expand_inline_diffs?: boolean;
+  download_scheme?: string;
+  date_format: DateFormat;
+  time_format: TimeFormat;
+  relative_date_in_change_table?: boolean;
+  diff_view: DiffViewMode;
+  size_bar_in_change_table?: boolean;
+  legacycid_in_change_table?: boolean;
+  mute_common_path_prefixes?: boolean;
+  signed_off_by?: boolean;
+  my: TopMenuItemInfo[];
+  change_table: string[];
+  email_strategy: EmailStrategy;
+  default_base_for_merges: DefaultBase;
+  publish_comments_on_push?: boolean;
+  work_in_progress_by_default?: boolean;
+  // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
+  email_format?: EmailFormat;
+  // The following property doesn't exist in RestAPI, it is added by GrRestApiInterface
+  default_diff_view?: DiffViewMode;
+}
+
+/**
+ * Contains information about diff images
+ * There is no RestAPI interface for it
+ */
+export interface ImagesForDiff {
+  baseImage: Base64ImageFile | null;
+  revisionImage: Base64ImageFile | null;
+}
+
+/**
+ * Contains information about diff image
+ * There is no RestAPI interface for it
+ */
+export interface Base64ImageFile extends Base64File {
+  _expectedType: string;
+  _name: string;
+}
+
+/**
+ * The ReviewInput entity contains information for adding a review to a revision
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-input
+ */
+export interface ReviewInput {
+  message?: string;
+  tag?: ReviewInputTag;
+  labels?: LabelNameToValuesMap;
+  comments?: PathToCommentsInputMap;
+  robot_comments?: PathToRobotCommentsMap;
+  drafts?: DraftsAction;
+  notify?: NotifyType;
+  notify_details?: RecipientTypeToNotifyInfoMap;
+  omit_duplicate_comments?: boolean;
+  on_behalf_of?: AccountId;
+  reviewers?: ReviewerInput[];
+  ready?: boolean;
+  work_in_progress?: boolean;
+  add_to_attention_set?: AttentionSetInput[];
+  remove_from_attention_set?: AttentionSetInput[];
+  ignore_automatic_attention_set_rules?: boolean;
+}
+
+/**
+ * The ReviewResult entity contains information regarding the updates that were
+ * made to a review.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-result
+ */
+export interface ReviewResult {
+  labels?: unknown;
+  // type of key is (AccountId | GroupId | EmailAddress)
+  reviewers?: {[key: string]: AddReviewerResult};
+  ready?: boolean;
+}
+
+/**
+ * The AddReviewerResult entity describes the result of adding a reviewer to a
+ * change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#add-reviewer-result
+ */
+export interface AddReviewerResult {
+  input: AccountId | GroupId | EmailAddress;
+  reviewers?: AccountInfo[];
+  ccs?: AccountInfo[];
+  error?: string;
+  confirm?: boolean;
+}
+
+export type LabelNameToValuesMap = {[labelName: string]: number};
+export type PathToCommentsInputMap = {[path: string]: CommentInput[]};
+export type PathToRobotCommentsMap = {[path: string]: RobotCommentInput[]};
+export type RecipientTypeToNotifyInfoMap = {
+  [recepientType: string]: NotifyInfo;
+};
+
+/**
+ * The RobotCommentInput entity contains information for creating an inline robot comment
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#robot-comment-input
+ */
+export type RobotCommentInput = RobotCommentInfo;
+
+/**
+ * This is what human, robot and draft comments can agree upon.
+ *
+ * Human, robot and saved draft comments all have a required id, but unsaved
+ * drafts do not. That is why the id is omitted from CommentInfo, such that it
+ * can be optional in Draft, but required in CommentInfo and RobotCommentInfo.
+ */
+export interface CommentBasics extends Omit<CommentInfo, 'id' | 'updated'> {
+  id?: UrlEncodedCommentId;
+  updated?: Timestamp;
+}
+
+/**
+ * The RobotCommentInfo entity contains information about a robot inline comment
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#robot-comment-info
+ */
+export interface RobotCommentInfo extends CommentInfo {
+  robot_id: RobotId;
+  robot_run_id: RobotRunId;
+  url?: string;
+  properties: {[propertyName: string]: string};
+  fix_suggestions: FixSuggestionInfo[];
+}
+export type PathToRobotCommentsInfoMap = {[path: string]: RobotCommentInfo[]};
+
+/**
+ * The FixSuggestionInfo entity represents a suggested fix
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-suggestion-info
+ */
+export interface FixSuggestionInfoInput {
+  description: string;
+  replacements: FixReplacementInfo[];
+}
+
+export interface FixSuggestionInfo extends FixSuggestionInfoInput {
+  fix_id: FixId;
+  description: string;
+  replacements: FixReplacementInfo[];
+}
+
+/**
+ * The FixReplacementInfo entity describes how the content of a file should be replaced by another content
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-replacement-info
+ */
+export interface FixReplacementInfo {
+  path: string;
+  range: CommentRange;
+  replacement: string;
+}
+
+/**
+ * The NotifyInfo entity contains detailed information about who should be notified about an update
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#notify-info
+ */
+export interface NotifyInfo {
+  accounts?: AccountId[];
+}
+
+/**
+ * The ReviewerInput entity contains information for adding a reviewer to a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#reviewer-input
+ */
+export interface ReviewerInput {
+  reviewer: AccountId | GroupId | EmailAddress;
+  state?: ReviewerState;
+  confirmed?: boolean;
+  notify?: NotifyType;
+  notify_details?: RecipientTypeToNotifyInfoMap;
+}
+
+/**
+ * The AttentionSetInput entity contains details for adding users to the attention set and removing them from it
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-input
+ */
+export interface AttentionSetInput {
+  user: AccountId;
+  reason: string;
+  notify?: NotifyType;
+  notify_details?: RecipientTypeToNotifyInfoMap;
+}
+
+/**
+ * The EditInfo entity contains information about a change edit
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#edit-info
+ */
+export interface EditInfo {
+  commit: CommitInfo;
+  base_patch_set_number: PatchSetNum;
+  base_revision: string;
+  ref: GitRef;
+  fetch?: ProtocolToFetchInfoMap;
+  files?: FileNameToFileInfoMap;
+}
+
+export type ProtocolToFetchInfoMap = {[protocol: string]: FetchInfo};
+export type FileNameToFileInfoMap = {[name: string]: FileInfo};
+
+/**
+ * Contains information about an account that can be added to a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#suggested-reviewer-info
+ */
+export interface SuggestedReviewerAccountInfo {
+  account: AccountInfo;
+  /**
+   * The total number of accounts in the suggestion - always 1
+   */
+  count: 1;
+}
+
+/**
+ * Contains information about a group that can be added to a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#suggested-reviewer-info
+ */
+export interface SuggestedReviewerGroupInfo {
+  group: GroupBaseInfo;
+  /**
+   * The total number of accounts that are members of the group is returned
+   * (this count includes members of nested groups)
+   */
+  count: number;
+  /**
+   * True if group is present and count is above the threshold where the
+   * confirmed flag must be passed to add the group as a reviewer
+   */
+  confirm?: boolean;
+}
+
+/**
+ * The SuggestedReviewerInfo entity contains information about a reviewer that can be added to a change (an account or a group)
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#suggested-reviewer-info
+ */
+export type SuggestedReviewerInfo =
+  | SuggestedReviewerAccountInfo
+  | SuggestedReviewerGroupInfo;
+
+export type Suggestion = SuggestedReviewerInfo | AccountInfo;
+
+export function isReviewerAccountSuggestion(
+  s: Suggestion
+): s is SuggestedReviewerAccountInfo {
+  return (s as SuggestedReviewerAccountInfo).account !== undefined;
+}
+
+export function isReviewerGroupSuggestion(
+  s: Suggestion
+): s is SuggestedReviewerGroupInfo {
+  return (s as SuggestedReviewerGroupInfo).group !== undefined;
+}
+
+export type RequestPayload = string | object;
+
+export type Password = string;
+
+/**
+ * The BranchInput entity contains information for the creation of a new branch
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-input
+ */
+export interface BranchInput {
+  ref?: BranchName; // refs/heads prefix is allowed, but can be omitted
+  revision?: string;
+}
+
+/**
+ * The TagInput entity contains information for creating a tag
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#tag-input
+ */
+export interface TagInput {
+  // ref: string; mentoined as required in doc, but it doesn't used anywher
+  revision?: string;
+  message?: string;
+}
+
+/**
+ * The IncludedInInfo entity contains information about the branches a change was merged into and tags it was tagged with
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#included-in-info
+ */
+export interface IncludedInInfo {
+  branches: BranchName[];
+  tags: TagName[];
+  external?: NameToExternalSystemsMap;
+}
+
+// It is unclear what is name here
+export type NameToExternalSystemsMap = {[name: string]: string[]};
+
+/**
+ * The PluginInfo entity describes a plugin.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-plugins.html#plugin-info
+ */
+export interface PluginInfo {
+  id: string;
+  version: string;
+  api_version?: string;
+  index_url?: string;
+  filename?: string;
+  disabled: boolean;
+}
+/**
+ * The PluginInput entity describes a plugin that should be installed.
+ */
+export interface PluginInput {
+  url: string;
+}
+
+/**
+ * The DocResult entity contains information about a document.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-documentation.html#doc-result
+ */
+export interface DocResult {
+  title: string;
+  url: string;
+}
+
+/**
+ * The TagInfo entity contains information about a tag.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#tag-info
+ **/
+export interface TagInfo {
+  ref: GitRef;
+  revision: string;
+  object?: string;
+  message?: string;
+  tagger?: GitPersonInfo;
+  created?: string;
+  can_delete: boolean;
+  web_links?: WebLinkInfo[];
+}
+
+/**
+ * The RelatedChangesInfo entity contains information about related changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#related-changes-info
+ */
+export interface RelatedChangesInfo {
+  changes: RelatedChangeAndCommitInfo[];
+}
+
+/**
+ * The RelatedChangeAndCommitInfo entity contains information about a related change and commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#related-change-and-commit-info
+ */
+export interface RelatedChangeAndCommitInfo {
+  project: RepoName;
+  change_id?: ChangeId;
+  commit: CommitInfoWithRequiredCommit;
+  _change_number?: NumericChangeId;
+  _revision_number?: number;
+  _current_revision_number?: number;
+  status?: ChangeStatus;
+  // The submittable property doesn't exist in the Gerrit API, but in the future
+  // we can bring this feature back. There is a frontend code and CSS styles for
+  // it and this property is added here to keep related frontend code unchanged.
+  submittable?: boolean;
+}
+
+/**
+ * The SubmittedTogetherInfo entity contains information about a collection of changes that would be submitted together.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together-info
+ */
+export interface SubmittedTogetherInfo {
+  changes: ChangeInfo[];
+  non_visible_changes: number;
+}
+
+/**
+ * The RevertSubmissionInfo entity describes the revert changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revert-submission-info
+ */
+export interface RevertSubmissionInfo {
+  revert_changes: ChangeInfo[];
+}
+
+/**
+ * The CherryPickInput entity contains information for cherry-picking a change to a new branch.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#cherrypick-input
+ */
+export interface CherryPickInput {
+  message?: string;
+  destination: BranchName;
+  base?: CommitId;
+  parent?: number;
+  notify?: NotifyType;
+  notify_details: RecipientTypeToNotifyInfoMap;
+  keep_reviewers?: boolean;
+  allow_conflicts?: boolean;
+  topic?: TopicName;
+  allow_empty?: boolean;
+}
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export interface MergeableInfo {
+  submit_type: SubmitType;
+  strategy?: MergeStrategy;
+  mergeable: boolean;
+  commit_merged?: boolean;
+  content_merged?: boolean;
+  conflicts?: string[];
+  mergeable_into?: string[];
+}
diff --git a/polygerrit-ui/app/types/custom-externs.js b/polygerrit-ui/app/types/custom-externs.js
deleted file mode 100644
index afa094c..0000000
--- a/polygerrit-ui/app/types/custom-externs.js
+++ /dev/null
@@ -1,63 +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.
- */
-
-/**
- * For the purposes of template type checking, externs should be added for
- * anything set on the window object. Note that sub-properties of these
- * declared properties are considered something separate.
- *
- * This file is only for template type checking, not used in Gerrit code.
- */
-
-/* eslint-disable no-var */
-/* eslint-disable no-unused-vars */
-/** @externs */
-// @unused
-
-var Gerrit;
-var GrAnnotation;
-var GrAttributeHelper;
-var GrChangeActionsInterface;
-var GrChangeReplyInterface;
-var GrDiffBuilder;
-var GrDiffBuilderImage;
-var GrDiffBuilderSideBySide;
-var GrDiffBuilderUnified;
-var GrDiffGroup;
-var GrDiffLine;
-var GrDomHooks;
-var GrEditConstants;
-var GrEtagDecorator;
-var GrFileListConstants;
-var GrGapiAuth;
-var GrGerritAuth;
-var GrLinkTextParser;
-var GrPluginEndpoints;
-var GrPopupInterface;
-var GrRangeNormalizer;
-var GrReporting;
-var GrReviewerUpdatesParser;
-var GrCountStringFormatter;
-var GrThemeApi;
-var SiteBasedCache;
-var FetchPromisesCache;
-var GrRestApiHelper;
-var GrDisplayNameUtils;
-var GrReviewerSuggestionsProvider;
-var moment;
-var page;
-var util;
\ No newline at end of file
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
new file mode 100644
index 0000000..529904a
--- /dev/null
+++ b/polygerrit-ui/app/types/events.ts
@@ -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 {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {PatchSetNum} from './common';
+import {UIComment} from '../utils/comment-util';
+
+export interface TitleChangeEventDetail {
+  title: string;
+}
+
+export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'title-change': TitleChangeEvent;
+  }
+}
+
+export interface PageErrorEventDetail {
+  response: Response;
+}
+
+export type PageErrorEvent = CustomEvent<PageErrorEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'page-error': PageErrorEvent;
+  }
+}
+
+export interface LocationChangeEventDetail {
+  hash: string;
+  pathname: string;
+}
+
+export type LocationChangeEvent = CustomEvent<LocationChangeEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'location-change': LocationChangeEvent;
+  }
+}
+
+export interface RpcLogEventDetail {
+  status: number | null;
+  method: string;
+  elapsed: number;
+  anonymizedUrl: string;
+}
+
+export type RpcLogEvent = CustomEvent<RpcLogEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'rpc-log': RpcLogEvent;
+  }
+}
+
+export interface ShortcutTriggeredEventDetail {
+  event: CustomKeyboardEvent;
+  goKey: boolean;
+  vKey: boolean;
+}
+
+export type ShortcutTriggeredEvent = CustomEvent<ShortcutTriggeredEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'shortcut-triggered': ShortcutTriggeredEvent;
+  }
+}
+
+export interface EditableContentSaveEventDetail {
+  content: string;
+}
+
+export type EditableContentSaveEvent = CustomEvent<
+  EditableContentSaveEventDetail
+>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'editable-content-save': EditableContentSaveEvent;
+  }
+}
+
+export interface OpenFixPreviewEventDetail {
+  patchNum?: PatchSetNum;
+  comment?: UIComment;
+}
+
+export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'open-fix-preview': OpenFixPreviewEvent;
+  }
+}
+
+// Type for the custom event to switch tab.
+interface SwitchTabEventDetail {
+  // name of the tab to set as active, from custom event
+  tab?: string;
+  // index of tab to set as active, from paper-tabs event
+  value?: number;
+  // scroll into the tab afterwards, from custom event
+  scrollIntoView?: boolean;
+}
+
+export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'show-primary-tab': SwitchTabEvent;
+    'show-secondary-tab': SwitchTabEvent;
+  }
+}
+
+export interface ReloadEventDetail {
+  clearPatchset: boolean;
+}
+
+export type ReloadEvent = CustomEvent<ReloadEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    reload: ReloadEvent;
+  }
+}
+
+export interface ShowAlertEventDetail {
+  message: string;
+}
+
+export type ShowAlertEvent = CustomEvent<ShowAlertEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'show-alert': ShowAlertEvent;
+  }
+}
+
+/**
+ * Keyboard events emitted from polymer elements.
+ */
+export interface CustomKeyboardEvent extends CustomEvent, EventApi {
+  event: CustomKeyboardEvent;
+  detail: {
+    keyboardEvent?: CustomKeyboardEvent;
+    // TODO(TS): maybe should mark as optional and check before accessing
+    key: string;
+  };
+  readonly altKey: boolean;
+  readonly changedTouches: TouchList;
+  readonly ctrlKey: boolean;
+  readonly metaKey: boolean;
+  readonly shiftKey: boolean;
+  readonly keyCode: number;
+  readonly repeat: boolean;
+}
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
new file mode 100644
index 0000000..2962158
--- /dev/null
+++ b/polygerrit-ui/app/types/globals.ts
@@ -0,0 +1,146 @@
+/**
+ * @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 {ParsedJSON} from './common';
+import {HighlightJS} from './types';
+
+export {};
+
+declare global {
+  interface Window {
+    CANONICAL_PATH?: string;
+    INITIAL_DATA?: {[key: string]: ParsedJSON};
+    ShadyCSS?: {
+      getComputedStyleValue(el: Element, name: string): string;
+    };
+    ShadyDOM?: {
+      inUse?: boolean;
+    };
+    HTMLImports?: {whenReady: (cb: () => void) => void};
+    linkify(
+      text: string,
+      options: {callback: (text: string, href?: string) => void}
+    ): void;
+    ASSETS_PATH?: string;
+    // TODO(TS): define gerrit type
+    Gerrit?: {
+      Nav?: unknown;
+      getRootElement?: unknown;
+      Auth?: unknown;
+      _pluginLoader?: unknown;
+      _endpoints?: unknown;
+      slotToContent?: unknown;
+      rangesEqual?: unknown;
+      SUGGESTIONS_PROVIDERS_USERS_TYPES?: unknown;
+      RevisionInfo?: unknown;
+      CoverageType?: unknown;
+      hiddenscroll?: unknown;
+      flushPreinstalls?: () => void;
+    };
+    // TODO(TS): define polymer type
+    Polymer?: {importHref?: unknown};
+    // TODO(TS): remove page when better workaround is found
+    // page shouldn't be exposed in window and it shouldn't be used
+    // it's defined because of limitations from typescript, which don't import .mjs
+    page?: unknown;
+    hljs?: HighlightJS;
+
+    DEFAULT_DETAIL_HEXES?: {
+      diffPage?: string;
+      changePage?: string;
+      dashboardPage?: string;
+    };
+    STATIC_RESOURCE_PATH?: string;
+
+    PRELOADED_QUERIES?: {
+      dashboardQuery?: string[];
+    };
+
+    VERSION_INFO?: string;
+
+    /** Enhancements on Gr elements or utils */
+    // TODO(TS): should clean up those and removing them may break certain plugin behaviors
+    // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
+    // use any for them for now
+    GrDisplayNameUtils: unknown;
+    GrAnnotation: unknown;
+    GrAttributeHelper: unknown;
+    GrDiffLine: unknown;
+    GrDiffLineType: unknown;
+    GrDiffGroup: unknown;
+    GrDiffGroupType: unknown;
+    GrDiffBuilder: unknown;
+    GrDiffBuilderSideBySide: unknown;
+    GrDiffBuilderImage: unknown;
+    GrDiffBuilderUnified: unknown;
+    GrDiffBuilderBinary: unknown;
+    GrChangeActionsInterface: unknown;
+    GrChangeReplyInterface: unknown;
+    GrEditConstants: unknown;
+    GrDomHooksManager: unknown;
+    GrDomHook: unknown;
+    GrEtagDecorator: unknown;
+    GrThemeApi: unknown;
+    SiteBasedCache: unknown;
+    FetchPromisesCache: unknown;
+    GrRestApiHelper: unknown;
+    GrLinkTextParser: unknown;
+    GrPluginEndpoints: unknown;
+    GrReviewerUpdatesParser: unknown;
+    GrPopupInterface: unknown;
+    GrCountStringFormatter: unknown;
+    GrReviewerSuggestionsProvider: unknown;
+    util: unknown;
+    Auth: unknown;
+    EventEmitter: unknown;
+    GrAdminApi: unknown;
+    GrAnnotationActionsContext: unknown;
+    GrAnnotationActionsInterface: unknown;
+    GrChangeMetadataApi: unknown;
+    GrEmailSuggestionsProvider: unknown;
+    GrGroupSuggestionsProvider: unknown;
+    GrEventHelper: unknown;
+    GrPluginRestApi: unknown;
+    GrRepoApi: unknown;
+    GrSettingsApi: unknown;
+    GrStylesApi: unknown;
+    PluginLoader: unknown;
+    GrPluginActionContext: unknown;
+    _apiUtils: {};
+  }
+
+  interface Performance {
+    // typescript doesn't know about the memory property.
+    // Define it here, so it can be used everywhere
+    memory?: {
+      jsHeapSizeLimit: number;
+      totalJSHeapSize: number;
+      usedJSHeapSize: number;
+    };
+  }
+
+  interface Event {
+    // path is a non-standard property. Actually, this is optional property,
+    // but marking it as optional breaks CustomKeyboardEvent
+    // TODO(TS): replace with composedPath if possible
+    readonly path: EventTarget[];
+  }
+
+  interface Error {
+    lineNumber?: number; // non-standard property
+    columnNumber?: number; // non-standard property
+  }
+}
diff --git a/polygerrit-ui/app/types/types.js b/polygerrit-ui/app/types/types.js
deleted file mode 100644
index 5408eea..0000000
--- a/polygerrit-ui/app/types/types.js
+++ /dev/null
@@ -1,311 +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.
- */
-
-// Type definitions used across multiple files in Gerrit
-
-/** @enum {string} */
-export const CoverageType = {
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  COVERED: 'COVERED',
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  NOT_COVERED: 'NOT_COVERED',
-  PARTIALLY_COVERED: 'PARTIALLY_COVERED',
-  /**
-   * You don't have to use this. If there is no coverage information for a
-   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
-   * end_character of the range will be ignored for this type.
-   */
-  NOT_INSTRUMENTED: 'NOT_INSTRUMENTED',
-};
-
-const Gerrit = window.Gerrit || {};
-
-/**
- * @typedef {{
- *   start_line: number,
- *   start_character: number,
- *   end_line: number,
- *   end_character: number,
- * }}
- */
-Gerrit.Range;
-
-/**
- * @typedef {{side: string, range: Gerrit.Range, hovering: boolean}}
- */
-Gerrit.HoveredRange;
-
-/**
- * @typedef {{
- *   side: string,
- *   type: Gerrit.CoverageType,
- *   code_range: Gerrit.Range,
- * }}
- */
-Gerrit.CoverageRange;
-
-/**
- * @typedef {{
- *    basePatchNum: (string|number),
- *    patchNum: (number),
- * }}
- */
-Gerrit.PatchRange;
-
-/**
- * @typedef {{
- *   changeNum: (string|number),
- *   endpoint: string,
- *   patchNum: (string|number|null|undefined),
- *   errFn: (function(?Response, string=)|null|undefined),
- *   params: (Object|null|undefined),
- *   fetchOptions: (Object|null|undefined),
- *   anonymizedEndpoint: (string|undefined),
- *   reportEndpointAsIs: (boolean|undefined),
- * }}
- */
-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
- * - body is a request payload.
- *     TODO (beckysiegel) remove need for number at least.
- * - errFn is a function to invoke when the request fails.
- * - cancelCondition is a function that, if provided and returns true, will
- *   cancel the response after it resolves.
- * - contentType is the content type of the body.
- * - headers is a key-value hash to describe HTTP headers for the request.
- * - parseResponse states whether the result should be parsed as a JSON
- *     object using getResponseObject.
- *
- * @typedef {{
- *   method: string,
- *   url: string,
- *   body: (string|number|Object|null|undefined),
- *   errFn: (function(?Response, string=)|null|undefined),
- *   contentType: (string|null|undefined),
- *   headers: (Object|undefined),
- *   parseResponse: (boolean|undefined),
- *   anonymizedUrl: (string|undefined),
- *   reportUrlAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.SendRequest;
-
-/**
- * @typedef {{
- *   changeNum: (string|number),
- *   method: string,
- *   patchNum: (string|number|undefined),
- *   endpoint: string,
- *   body: (string|number|Object|null|undefined),
- *   errFn: (function(?Response, string=)|null|undefined),
- *   contentType: (string|null|undefined),
- *   headers: (Object|undefined),
- *   parseResponse: (boolean|undefined),
- *   anonymizedEndpoint: (string|undefined),
- *   reportEndpointAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.ChangeSendRequest;
-
-/**
- * @typedef {{
- *    url: string,
- *    fetchOptions: (Object|null|undefined),
- *    anonymizedUrl: (string|undefined),
- * }}
- */
-Gerrit.FetchRequest;
-
-/**
- * Object to describe a request for passing into fetchJSON or fetchRawJSON.
- * - url is the URL for the request (excluding get params)
- * - errFn is a function to invoke when the request fails.
- * - cancelCondition is a function that, if provided and returns true, will
- *     cancel the response after it resolves.
- * - params is a key-value hash to specify get params for the request URL.
- *
- * @typedef {{
- *    url: string,
- *    errFn: (function(?Response, string=)|null|undefined),
- *    cancelCondition: (function()|null|undefined),
- *    params: (Object|null|undefined),
- *    fetchOptions: (Object|null|undefined),
- *    anonymizedUrl: (string|undefined),
- *    reportUrlAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.FetchJSONRequest;
-
-/**
- * @typedef {{
- *    message: string,
- *    icon: string,
- *    class: string,
- *  }}
- */
-Gerrit.PushCertificateValidation;
-
-/**
- * Object containing layout values to be used in rendering size-bars.
- * `max{Inserted,Deleted}` represent the largest values of the
- * `lines_inserted` and `lines_deleted` fields of the files respectively. The
- * `max{Addition,Deletion}Width` represent the width of the graphic allocated
- * to the insertion or deletion side respectively. Finally, the
- * `deletionOffset` value represents the x-position for the deletion bar.
- *
- * @typedef {{
- *    maxInserted: number,
- *    maxDeleted: number,
- *    maxAdditionWidth: number,
- *    maxDeletionWidth: number,
- *    deletionOffset: number,
- * }}
- */
-Gerrit.LayoutStats;
-
-/**
- * @typedef {{
- *    changeNum: number,
- *    path: string,
- *    patchRange: !Gerrit.PatchRange,
- *    projectConfig: (Object|undefined),
- * }}
- */
-Gerrit.CommentMeta;
-
-/**
- * @typedef {{
- *    meta: !Gerrit.CommentMeta,
- *    left: !Array,
- *    right: !Array,
- * }}
- */
-Gerrit.CommentsBySide;
-
-/**
- * The DiffIntralineInfo entity contains information about intraline edits in a
- * file.
- *
- * The information consists of a list of <skip length, mark length> pairs, where
- * the skip length is the number of characters between the end of the previous
- * edit and the start of this edit, and the mark length is the number of edited
- * characters following the skip. The start of the edits is from the beginning
- * of the related diff content lines.
- *
- * Note that the implied newline character at the end of each line is included
- * in the length calculation, and thus it is possible for the edits to span
- * newlines.
- *
- * @typedef {!Array<number>}
- */
-Gerrit.IntralineInfo;
-
-/**
- * A portion of the diff that is treated the same.
- *
- * Called `DiffContent` in the API, see
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
- *
- * @typedef {{
- *  ab: ?Array<!string>,
- *  a: ?Array<!string>,
- *  b: ?Array<!string>,
- *  skip: ?number,
- *  edit_a: ?Array<!Gerrit.IntralineInfo>,
- *  edit_b: ?Array<!Gerrit.IntralineInfo>,
- *  due_to_rebase: ?boolean,
- *  common: ?boolean
- * }}
- */
-Gerrit.DiffChunk;
-
-/**
- * Special line number which should not be collapsed into a shared region.
- *
- * @typedef {{
- *  number: number,
- *  leftSide: boolean
- * }}
- */
-Gerrit.LineOfInterest;
-
-/**
- * @typedef {{
- *    html: Node,
- *    position: number,
- *    length: number,
- * }}
- */
-Gerrit.CommentLinkItem;
-
-/**
- * @typedef {{
- *   name: string,
- *   value: Object,
- * }}
- */
-Gerrit.GrSuggestionItem;
-
-/**
- * @typedef {{
- *    getSuggestions: function(string): Promise<Array<Object>>,
- *    makeSuggestionItem: function(Object): Gerrit.GrSuggestionItem,
- * }}
- */
-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/types/types.ts b/polygerrit-ui/app/types/types.ts
new file mode 100644
index 0000000..b40d618
--- /dev/null
+++ b/polygerrit-ui/app/types/types.ts
@@ -0,0 +1,239 @@
+/**
+ * @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 {DiffViewMode, Side} from '../constants/constants';
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line';
+import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {
+  ChangeId,
+  CommitId,
+  NumericChangeId,
+  PatchRange,
+  PatchSetNum,
+} from './common';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+
+export function notUndefined<T>(x: T | undefined): x is T {
+  return x !== undefined;
+}
+
+export interface FixIronA11yAnnouncer extends IronA11yAnnouncer {
+  requestAvailability(): void;
+}
+
+export interface CommitRange {
+  baseCommit: CommitId;
+  commit: CommitId;
+}
+
+export interface CoverageRange {
+  type: CoverageType;
+  side: Side;
+  code_range: {end_line: number; start_line: number};
+}
+
+export enum CoverageType {
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  COVERED = 'COVERED',
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  NOT_COVERED = 'NOT_COVERED',
+  PARTIALLY_COVERED = 'PARTIALLY_COVERED',
+  /**
+   * You don't have to use this. If there is no coverage information for a
+   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+   * end_character of the range will be ignored for this type.
+   */
+  NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
+}
+
+export enum ErrorType {
+  AUTH = 'AUTH',
+  NETWORK = 'NETWORK',
+  GENERIC = 'GENERIC',
+}
+
+/**
+ * We would like to access the the typed `nativeInput` of PaperInputElement, so
+ * we are creating this wrapper.
+ */
+export type PaperInputElementExt = PaperInputElement & {
+  $: {nativeInput?: Element};
+};
+
+/**
+ * If Polymer would have exported DomApiNative from its dom.js utility, then we
+ * would probably not need this type. We just use it for casting the return
+ * value of dom(element).
+ */
+export interface PolymerDomWrapper {
+  getOwnerRoot(): Node & OwnerRoot;
+  getEffectiveChildNodes(): Node[];
+  observeNodes(
+    callback: (p0: {
+      target: HTMLElement;
+      addedNodes: Element[];
+      removedNodes: Element[];
+    }) => void
+  ): FlattenedNodesObserver;
+  unobserveNodes(observerHandle: FlattenedNodesObserver): void;
+}
+
+export interface OwnerRoot {
+  host?: HTMLElement;
+}
+
+/**
+ * Event type for an event fired by Polymer for an element generated from a
+ * dom-repeat template.
+ */
+export interface PolymerDomRepeatEvent<TModel = unknown> extends Event {
+  model: PolymerDomRepeatEventModel<TModel>;
+}
+
+/**
+ * Event type for an event fired by Polymer for an element generated from a
+ * dom-repeat template.
+ */
+export interface PolymerDomRepeatCustomEvent<
+  TModel = unknown,
+  TDetail = unknown
+> extends CustomEvent<TDetail> {
+  model: PolymerDomRepeatEventModel<TModel>;
+}
+
+/**
+ * Model containing additional information about the dom-repeat element
+ * that fired an event.
+ *
+ * Note: This interface is valid only if both dom-repeat properties 'as' and
+ * 'indexAs' have default values ('item' and 'index' correspondingly)
+ */
+export interface PolymerDomRepeatEventModel<T> {
+  /**
+   * The item corresponding to the element in the dom-repeat.
+   */
+  item: T;
+
+  /**
+   * The index of the element in the dom-repeat.
+   */
+  index: number;
+  get: (name: string) => T;
+  set: (name: string, val: T) => void;
+}
+
+/** https://highlightjs.readthedocs.io/en/latest/api.html */
+export interface HighlightJSResult {
+  value: string;
+  top: unknown;
+}
+
+/** https://highlightjs.readthedocs.io/en/latest/api.html */
+export interface HighlightJS {
+  configure(options: {classPrefix: string}): void;
+  getLanguage(languageName: string): unknown | undefined;
+  highlight(
+    languageName: string,
+    code: string,
+    ignore_illegals: boolean,
+    continuation: unknown
+  ): HighlightJSResult;
+}
+
+export type DiffLayerListener = (
+  start: number,
+  end: number,
+  side: Side
+) => void;
+
+export interface DiffLayer {
+  annotate(el: HTMLElement, lineEl: HTMLElement, line: GrDiffLine): void;
+  addListener?(listener: DiffLayerListener): void;
+  removeListener?(listener: DiffLayerListener): void;
+}
+
+export interface ChangeViewState {
+  changeNum: NumericChangeId | null;
+  patchRange: PatchRange | null;
+  selectedFileIndex: number;
+  showReplyDialog: boolean;
+  showDownloadDialog: boolean;
+  diffMode: DiffViewMode | null;
+  numFilesShown: number | null;
+  scrollTop?: number;
+  diffViewMode?: boolean;
+}
+
+export interface ChangeListViewState {
+  changeNum?: ChangeId;
+  patchRange?: PatchRange;
+  // TODO(TS): seems only one of 2 selected... is required
+  selectedFileIndex?: number;
+  selectedChangeIndex?: number;
+  showReplyDialog?: boolean;
+  showDownloadDialog?: boolean;
+  diffMode?: DiffViewMode;
+  numFilesShown?: number;
+  scrollTop?: number;
+  query?: string | null;
+  offset?: number;
+}
+
+export interface DashboardViewState {
+  selectedChangeIndex: number;
+}
+
+export interface ViewState {
+  changeView: ChangeViewState;
+  changeListView: ChangeListViewState;
+  dashboardView: DashboardViewState;
+}
+
+export interface PatchSetFile {
+  path: string;
+  basePath?: string;
+  patchNum?: PatchSetNum;
+}
+
+export interface PatchNumOnly {
+  patchNum: PatchSetNum;
+}
+
+export function isPatchSetFile(
+  x: PatchSetFile | PatchNumOnly
+): x is PatchSetFile {
+  return !!(x as PatchSetFile).path;
+}
+
+export interface FileRange {
+  basePath?: string;
+  path: string;
+}
+
+export function isPolymerSpliceChange<
+  T,
+  U extends Array<{} | null | undefined>
+>(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
+  return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
+}
diff --git a/polygerrit-ui/app/utils/access-util.ts b/polygerrit-ui/app/utils/access-util.ts
new file mode 100644
index 0000000..44830e2
--- /dev/null
+++ b/polygerrit-ui/app/utils/access-util.ts
@@ -0,0 +1,186 @@
+/**
+ * @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 {LabelName} from '../types/common';
+
+export enum AccessPermissionId {
+  ABANDON = 'abandon',
+  ADD_PATCH_SET = 'addPatchSet',
+  CREATE = 'create',
+  CREATE_TAG = 'createTag',
+  CREATE_SIGNED_TAG = 'createSignedTag',
+  DELETE = 'delete',
+  DELETE_CHANGES = 'deleteChanges',
+  DELETE_OWN_CHANGES = 'deleteOwnChanges',
+  EDIT_ASSIGNEE = 'editAssignee',
+  EDIT_HASHTAGS = 'editHashtags',
+  EDIT_TOPIC_NAME = 'editTopicName',
+  FORGE_AUTHOR = 'forgeAuthor',
+  FORGE_COMMITTER = 'forgeCommitter',
+  FORGE_SERVER_AS_COMMITTER = 'forgeServerAsCommitter',
+  OWNER = 'owner',
+  PUSH = 'push',
+  PUSH_MERGE = 'pushMerge',
+  READ = 'read',
+  REBASE = 'rebase',
+  REVERT = 'revert',
+  REMOVE_REVIEWER = 'removeReviewer',
+  SUBMIT = 'submit',
+  SUBMIT_AS = 'submitAs',
+  TOGGLE_WIP_STATE = 'toggleWipState',
+  VIEW_PRIVATE_CHANGES = 'viewPrivateChanges',
+
+  PRIORITY = 'priority',
+}
+
+export const AccessPermissions: {[id: string]: AccessPermission} = {
+  [AccessPermissionId.ABANDON]: {
+    id: AccessPermissionId.ABANDON,
+    name: 'Abandon',
+  },
+  [AccessPermissionId.ADD_PATCH_SET]: {
+    id: AccessPermissionId.ADD_PATCH_SET,
+    name: 'Add Patch Set',
+  },
+  [AccessPermissionId.CREATE]: {
+    id: AccessPermissionId.CREATE,
+    name: 'Create Reference',
+  },
+  [AccessPermissionId.CREATE_TAG]: {
+    id: AccessPermissionId.CREATE_TAG,
+    name: 'Create Annotated Tag',
+  },
+  [AccessPermissionId.CREATE_SIGNED_TAG]: {
+    id: AccessPermissionId.CREATE_SIGNED_TAG,
+    name: 'Create Signed Tag',
+  },
+  [AccessPermissionId.DELETE]: {
+    id: AccessPermissionId.DELETE,
+    name: 'Delete Reference',
+  },
+  [AccessPermissionId.DELETE_CHANGES]: {
+    id: AccessPermissionId.DELETE_CHANGES,
+    name: 'Delete Changes',
+  },
+  [AccessPermissionId.DELETE_OWN_CHANGES]: {
+    id: AccessPermissionId.DELETE_OWN_CHANGES,
+    name: 'Delete Own Changes',
+  },
+  [AccessPermissionId.EDIT_ASSIGNEE]: {
+    id: AccessPermissionId.EDIT_ASSIGNEE,
+    name: 'Edit Assignee',
+  },
+  [AccessPermissionId.EDIT_HASHTAGS]: {
+    id: AccessPermissionId.EDIT_HASHTAGS,
+    name: 'Edit Hashtags',
+  },
+  [AccessPermissionId.EDIT_TOPIC_NAME]: {
+    id: AccessPermissionId.EDIT_TOPIC_NAME,
+    name: 'Edit Topic Name',
+  },
+  [AccessPermissionId.FORGE_AUTHOR]: {
+    id: AccessPermissionId.FORGE_AUTHOR,
+    name: 'Forge Author Identity',
+  },
+  [AccessPermissionId.FORGE_COMMITTER]: {
+    id: AccessPermissionId.FORGE_COMMITTER,
+    name: 'Forge Committer Identity',
+  },
+  [AccessPermissionId.FORGE_SERVER_AS_COMMITTER]: {
+    id: AccessPermissionId.FORGE_SERVER_AS_COMMITTER,
+    name: 'Forge Server Identity',
+  },
+  [AccessPermissionId.OWNER]: {
+    id: AccessPermissionId.OWNER,
+    name: 'Owner',
+  },
+  [AccessPermissionId.PUSH]: {
+    id: AccessPermissionId.PUSH,
+    name: 'Push',
+  },
+  [AccessPermissionId.PUSH_MERGE]: {
+    id: AccessPermissionId.PUSH_MERGE,
+    name: 'Push Merge Commit',
+  },
+  [AccessPermissionId.READ]: {
+    id: AccessPermissionId.READ,
+    name: 'Read',
+  },
+  [AccessPermissionId.REBASE]: {
+    id: AccessPermissionId.REBASE,
+    name: 'Rebase',
+  },
+  [AccessPermissionId.REVERT]: {
+    id: AccessPermissionId.REVERT,
+    name: 'Revert',
+  },
+  [AccessPermissionId.REMOVE_REVIEWER]: {
+    id: AccessPermissionId.REMOVE_REVIEWER,
+    name: 'Remove Reviewer',
+  },
+  [AccessPermissionId.SUBMIT]: {
+    id: AccessPermissionId.SUBMIT,
+    name: 'Submit',
+  },
+  [AccessPermissionId.SUBMIT_AS]: {
+    id: AccessPermissionId.SUBMIT_AS,
+    name: 'Submit (On Behalf Of)',
+  },
+  [AccessPermissionId.TOGGLE_WIP_STATE]: {
+    id: AccessPermissionId.TOGGLE_WIP_STATE,
+    name: 'Toggle Work In Progress State',
+  },
+  [AccessPermissionId.VIEW_PRIVATE_CHANGES]: {
+    id: AccessPermissionId.VIEW_PRIVATE_CHANGES,
+    name: 'View Private Changes',
+  },
+};
+
+export interface AccessPermission {
+  id: AccessPermissionId;
+  name: string;
+  label?: LabelName;
+}
+
+export interface PermissionArrayItem<T> {
+  id: string;
+  value: T;
+}
+
+export type PermissionArray<T> = Array<PermissionArrayItem<T>>;
+
+/**
+ * @return a sorted array sorted by the id of the original
+ *    object.
+ */
+export function toSortedPermissionsArray<T>(obj?: {
+  [permissionId: string]: T;
+}): PermissionArray<T> {
+  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)
+    );
+}
diff --git a/polygerrit-ui/app/utils/access-util_test.ts b/polygerrit-ui/app/utils/access-util_test.ts
new file mode 100644
index 0000000..f098d89
--- /dev/null
+++ b/polygerrit-ui/app/utils/access-util_test.ts
@@ -0,0 +1,51 @@
+/**
+ * @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 '../test/common-test-setup-karma';
+import {toSortedPermissionsArray} from './access-util';
+
+suite('access-util tests', () => {
+  test('toSortedPermissionsArray', () => {
+    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(toSortedPermissionsArray(rules), expectedResult);
+  });
+});
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
new file mode 100644
index 0000000..7e425f8
--- /dev/null
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -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 {AccountId, AccountInfo, EmailAddress} from '../types/common';
+import {AccountTag} from '../constants/constants';
+
+export function accountKey(account: AccountInfo): AccountId | EmailAddress {
+  if (account._account_id) return account._account_id;
+  if (account.email) return account.email;
+  throw new Error('Account has neither _account_id nor email.');
+}
+
+export function isServiceUser(account?: AccountInfo): boolean {
+  return !!account?.tags?.includes(AccountTag.SERVICE_USER);
+}
+
+export function removeServiceUsers(accounts?: AccountInfo[]): AccountInfo[] {
+  return accounts?.filter(a => !isServiceUser(a)) || [];
+}
diff --git a/polygerrit-ui/app/utils/account-util_test.js b/polygerrit-ui/app/utils/account-util_test.js
new file mode 100644
index 0000000..0628f2d
--- /dev/null
+++ b/polygerrit-ui/app/utils/account-util_test.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 '../test/common-test-setup-karma.js';
+import {isServiceUser, removeServiceUsers} from './account-util.js';
+import {AccountTag} from '../constants/constants.js';
+
+const EMPTY = {};
+const ERNIE = {name: 'Ernie'};
+const KERMIT = {name: 'Kermit', tags: ['FROG']};
+const SERVY = {name: 'Servy', tags: [AccountTag.SERVICE_USER]};
+const BOTTY = {name: 'Botty', tags: [AccountTag.SERVICE_USER]};
+
+suite('account-util tests 3', () => {
+  test('isServiceUser', () => {
+    assert.isFalse(isServiceUser());
+    assert.isFalse(isServiceUser(EMPTY));
+    assert.isFalse(isServiceUser(ERNIE));
+    assert.isFalse(isServiceUser(KERMIT));
+    assert.isTrue(isServiceUser(SERVY));
+    assert.isTrue(isServiceUser(BOTTY));
+  });
+
+  test('removeServiceUsers', () => {
+    assert.sameMembers(removeServiceUsers([]), []);
+    assert.sameMembers(removeServiceUsers([EMPTY, ERNIE, KERMIT]),
+        [EMPTY, ERNIE, KERMIT]);
+    assert.sameMembers(removeServiceUsers([SERVY, BOTTY]), []);
+    assert.sameMembers(removeServiceUsers([EMPTY, SERVY, ERNIE, BOTTY, KERMIT]),
+        [EMPTY, ERNIE, KERMIT]);
+  });
+});
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
new file mode 100644
index 0000000..06f4e3a
--- /dev/null
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -0,0 +1,256 @@
+/**
+ * @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.
+ */
+import {
+  GerritNav,
+  GerritView,
+  RepoDetailView,
+  GroupDetailView,
+} from '../elements/core/gr-navigation/gr-navigation';
+import {
+  RepoName,
+  GroupId,
+  AccountDetailInfo,
+  AccountCapabilityInfo,
+} from '../types/common';
+import {MenuLink} from '../elements/plugins/gr-admin-api/gr-admin-api';
+import {hasOwnProperty} from './common-util';
+
+const ADMIN_LINKS: NavLink[] = [
+  {
+    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',
+  },
+];
+
+export interface AdminLink {
+  url: string;
+  text: string;
+  capability: string | null;
+  noBaseUrl: boolean;
+  view: null;
+  viewableToAll: boolean;
+  target: '_blank' | null;
+}
+
+export interface AdminLinks {
+  links: NavLink[];
+  expandedSection?: SubsectionInterface;
+}
+
+export function getAdminLinks(
+  account: AccountDetailInfo | undefined,
+  getAccountCapabilities: () => Promise<AccountCapabilityInfo>,
+  getAdminMenuLinks: () => MenuLink[],
+  options?: AdminNavLinksOption
+): Promise<AdminLinks> {
+  if (!account) {
+    return Promise.resolve(
+      _filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
+    );
+  }
+  return getAccountCapabilities().then(capabilities =>
+    _filterLinks(
+      link => !link.capability || hasOwnProperty(capabilities, link.capability),
+      getAdminMenuLinks,
+      options
+    )
+  );
+}
+
+function _filterLinks(
+  filterFn: (link: NavLink) => boolean,
+  getAdminMenuLinks: () => MenuLink[],
+  options?: AdminNavLinksOption
+): AdminLinks {
+  let links: NavLink[] = ADMIN_LINKS.slice(0);
+  let expandedSection: SubsectionInterface | undefined = undefined;
+
+  const isExternalLink = (link: MenuLink) => link.url[0] !== '/';
+
+  // Append top-level links that are defined by plugins.
+  links.push(
+    ...getAdminMenuLinks().map((link: MenuLink) => {
+      return {
+        url: link.url,
+        name: link.text,
+        capability: link.capability || undefined,
+        noBaseUrl: !isExternalLink(link),
+        view: null,
+        viewableToAll: !link.capability,
+        target: isExternalLink(link) ? '_blank' : null,
+      };
+    })
+  );
+
+  links = links.filter(filterFn);
+
+  const filteredLinks: NavLink[] = [];
+  const repoName = options && options.repoName;
+  const groupId = options && options.groupId;
+  const groupName = options && options.groupName;
+  const groupIsInternal = options && options.groupIsInternal;
+  const isAdmin = options && options.isAdmin;
+  const groupOwner = options && 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 = {...link};
+    if (linkCopy.name === 'Repositories' && repoName) {
+      linkCopy.subsection = getRepoSubsections(repoName);
+      expandedSection = linkCopy.subsection;
+    } else if (linkCopy.name === 'Groups' && groupId && groupName) {
+      linkCopy.subsection = getGroupSubsections(
+        groupId,
+        groupName,
+        groupIsInternal,
+        isAdmin,
+        groupOwner
+      );
+      expandedSection = linkCopy.subsection;
+    }
+    filteredLinks.push(linkCopy);
+  }
+  return {links: filteredLinks, expandedSection};
+}
+
+export function getGroupSubsections(
+  groupId: GroupId,
+  groupName: string,
+  groupIsInternal?: boolean,
+  isAdmin?: boolean,
+  groupOwner?: boolean
+) {
+  const children: SubsectionInterface[] = [];
+  const subsection: SubsectionInterface = {
+    name: groupName,
+    view: GerritNav.View.GROUP,
+    url: GerritNav.getUrlForGroup(groupId),
+    children,
+  };
+  if (groupIsInternal) {
+    children.push({
+      name: 'Members',
+      detailType: GerritNav.GroupDetailView.MEMBERS,
+      view: GerritNav.View.GROUP,
+      url: GerritNav.getUrlForGroupMembers(groupId),
+    });
+  }
+  if (groupIsInternal && (isAdmin || groupOwner)) {
+    children.push({
+      name: 'Audit Log',
+      detailType: GerritNav.GroupDetailView.LOG,
+      view: GerritNav.View.GROUP,
+      url: GerritNav.getUrlForGroupLog(groupId),
+    });
+  }
+  return subsection;
+}
+
+export function getRepoSubsections(repoName: 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),
+      },
+    ],
+  };
+}
+
+export interface SubsectionInterface {
+  name: string;
+  view: GerritView;
+  detailType?: RepoDetailView | GroupDetailView;
+  url: string;
+  children?: SubsectionInterface[];
+}
+
+export interface AdminNavLinksOption {
+  repoName?: RepoName;
+  groupId?: GroupId;
+  groupName?: string;
+  groupIsInternal?: boolean;
+  isAdmin?: boolean;
+  groupOwner?: boolean;
+}
+
+export interface NavLink {
+  name: string;
+  noBaseUrl: boolean;
+  url: string;
+  view: string | null;
+  viewableToAll?: boolean;
+  section?: string;
+  capability?: string;
+  target?: string | null;
+  subsection?: SubsectionInterface;
+}
diff --git a/polygerrit-ui/app/utils/admin-nav-util_test.js b/polygerrit-ui/app/utils/admin-nav-util_test.js
new file mode 100644
index 0000000..a3dc87a
--- /dev/null
+++ b/polygerrit-ui/app/utils/admin-nav-util_test.js
@@ -0,0 +1,335 @@
+/**
+ * @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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {getAdminLinks} from './admin-nav-util.js';
+
+suite('gr-admin-nav-behavior tests', () => {
+  let capabilityStub;
+  let menuLinkStub;
+
+  setup(() => {
+    capabilityStub = sinon.stub();
+    menuLinkStub = sinon.stub().returns([]);
+  });
+
+  const testAdminLinks = (account, options, expected, done) => {
+    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);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
new file mode 100644
index 0000000..119b09b
--- /dev/null
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -0,0 +1,44 @@
+/**
+ * @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.
+ */
+
+/**
+ * @param 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.
+ */
+export function asyncForeach<T>(
+  array: T[],
+  fn: (item: T, stopCallback: () => void) => Promise<unknown>
+): Promise<T | void> {
+  if (!array.length) {
+    return Promise.resolve();
+  }
+  let stop = false;
+  const stopCallback = () => {
+    stop = true;
+  };
+  return fn(array[0], stopCallback).then(() => {
+    if (stop) {
+      return Promise.resolve();
+    }
+    return asyncForeach(array.slice(1), fn);
+  });
+}
diff --git a/polygerrit-ui/app/utils/async-util_test.js b/polygerrit-ui/app/utils/async-util_test.js
new file mode 100644
index 0000000..df29e97
--- /dev/null
+++ b/polygerrit-ui/app/utils/async-util_test.js
@@ -0,0 +1,46 @@
+/**
+ * @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 '../test/common-test-setup-karma.js';
+import {asyncForeach} from './async-util.js';
+
+suite('async-util tests', () => {
+  test('loops over each item', () => {
+    const fn = sinon.stub().returns(Promise.resolve());
+    return 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 asyncForeach([1, 2, 3], fn)
+        .then(() => {
+          assert.isTrue(stub.calledOnce);
+          assert.equal(stub.lastCall.args[0], 1);
+        });
+  });
+});
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
new file mode 100644
index 0000000..b0aefcb
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -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.
+ */
+
+import {AccountInfo, ChangeInfo} from '../types/common';
+import {isServiceUser} from './account-util';
+
+// You would typically use a ServerInfo here, but this utility does not care
+// about all the other parameters in that object.
+interface SimpleServerInfo {
+  change?: {
+    enable_attention_set?: boolean;
+  };
+}
+
+const CONFIG_ENABLED: SimpleServerInfo = {
+  change: {enable_attention_set: true},
+};
+
+export function isAttentionSetEnabled(config?: SimpleServerInfo): boolean {
+  return !!config?.change?.enable_attention_set;
+}
+
+export function canHaveAttention(account?: AccountInfo): boolean {
+  return !!account?._account_id && !isServiceUser(account);
+}
+
+export function hasAttention(
+  config?: SimpleServerInfo,
+  account?: AccountInfo,
+  change?: ChangeInfo
+): boolean {
+  return (
+    isAttentionSetEnabled(config) &&
+    canHaveAttention(account) &&
+    !!change?.attention_set?.hasOwnProperty(account!._account_id!)
+  );
+}
+
+export function getReason(account?: AccountInfo, change?: ChangeInfo) {
+  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  const entry = change!.attention_set![account!._account_id!];
+  return entry?.reason ? entry.reason : '';
+}
+
+export function getLastUpdate(account?: AccountInfo, change?: ChangeInfo) {
+  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  const entry = change!.attention_set![account!._account_id!];
+  return entry?.last_update ? entry.last_update : '';
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.js
new file mode 100644
index 0000000..71735d5
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util_test.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 '../test/common-test-setup-karma.js';
+import {
+  hasAttention, getReason,
+} from './attention-set-util.js';
+
+const KERMIT = {
+  email: 'kermit@gmail.com',
+  username: 'kermit',
+  name: 'Kermit The Frog',
+  _account_id: '31415926535',
+};
+
+suite('attention-set-util', () => {
+  test('hasAttention', () => {
+    const config = {
+      change: {enable_attention_set: true},
+    };
+    const change = {
+      attention_set: {
+        31415926535: {
+          reason: 'a good reason',
+        },
+      },
+    };
+
+    assert.isTrue(hasAttention(config, KERMIT, change));
+  });
+
+  test('getReason', () => {
+    const change = {
+      attention_set: {
+        31415926535: {
+          reason: 'a good reason',
+        },
+      },
+    };
+
+    assert.equal(getReason(KERMIT, change), 'a good reason');
+  });
+});
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
new file mode 100644
index 0000000..4c0bdef
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -0,0 +1,204 @@
+/**
+ * @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 {getBaseUrl} from './url-util';
+import {ChangeStatus} from '../constants/constants';
+import {
+  NumericChangeId,
+  PatchSetNum,
+  ChangeInfo,
+  AccountInfo,
+} from '../types/common';
+import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+// This can be wrong! See WARNING above
+interface ChangeStatusesOptions {
+  mergeable: boolean; // This can be wrong! See WARNING above
+  submitEnabled: boolean; // This can be wrong! See WARNING above
+}
+
+export const ChangeDiffType = {
+  ADDED: 'ADDED',
+  COPIED: 'COPIED',
+  DELETED: 'DELETED',
+  MODIFIED: 'MODIFIED',
+  RENAMED: 'RENAMED',
+  REWRITE: 'REWRITE',
+};
+
+// Must be kept in sync with the ListChangesOption enum and protobuf.
+export const 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,
+};
+
+export function listChangesOptionsToHex(...args: number[]) {
+  let v = 0;
+  for (let i = 0; i < args.length; i++) {
+    v |= 1 << args[i];
+  }
+  return v.toString(16);
+}
+
+export function changeBaseURL(
+  project: string,
+  changeNum: NumericChangeId,
+  patchNum: PatchSetNum
+): string {
+  let v = `${getBaseUrl()}/changes/${encodeURIComponent(project)}~${changeNum}`;
+  if (patchNum) {
+    v += `/revisions/${patchNum}`;
+  }
+  return v;
+}
+
+export function changePath(changeNum: NumericChangeId) {
+  return `${getBaseUrl()}/c/${changeNum}`;
+}
+
+export function changeIsOpen(change?: ChangeInfo | ParsedChangeInfo | null) {
+  return change?.status === ChangeStatus.NEW;
+}
+
+export function changeIsMerged(change?: ChangeInfo | ParsedChangeInfo | null) {
+  return change?.status === ChangeStatus.MERGED;
+}
+
+export function changeIsAbandoned(
+  change?: ChangeInfo | ParsedChangeInfo | null
+) {
+  return change?.status === ChangeStatus.ABANDONED;
+}
+
+export function changeStatuses(
+  change: ChangeInfo,
+  opt_options?: ChangeStatusesOptions
+) {
+  const states = [];
+  if (change.status === ChangeStatus.MERGED) {
+    states.push('Merged');
+  } else if (change.status === 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;
+}
+
+export function isOwner(change?: ChangeInfo, account?: AccountInfo) {
+  if (!change || !account) return false;
+  return change.owner?._account_id === account._account_id;
+}
+
+export function changeStatusString(change: ChangeInfo) {
+  return changeStatuses(change).join(', ');
+}
+
+export function isRemovableReviewer(
+  change?: ChangeInfo,
+  reviewer?: AccountInfo
+): boolean {
+  if (!change?.removable_reviewers || !reviewer) return false;
+  return change.removable_reviewers.some(
+    account =>
+      account._account_id === reviewer._account_id ||
+      (!reviewer._account_id && account.email === reviewer.email)
+  );
+}
diff --git a/polygerrit-ui/app/utils/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
new file mode 100644
index 0000000..97884af
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-util_test.js
@@ -0,0 +1,274 @@
+/**
+ * @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 '../test/common-test-setup-karma.js';
+import {
+  changeBaseURL,
+  changeIsOpen,
+  changeIsMerged,
+  changeIsAbandoned,
+  changePath,
+  changeStatuses,
+  changeStatusString,
+  isRemovableReviewer,
+} from './change-util.js';
+
+suite('change-util tests', () => {
+  let originalCanonicalPath;
+
+  suiteSetup(() => {
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = '/r';
+  });
+
+  suiteTeardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('changeBaseURL', () => {
+    assert.deepEqual(
+        changeBaseURL('test/project', '1', '2'),
+        '/r/changes/test%2Fproject~1/revisions/2'
+    );
+  });
+
+  test('changePath', () => {
+    assert.deepEqual(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 = changeStatuses(change);
+    const statusString = changeStatusString(change);
+    assert.deepEqual(statuses, []);
+    assert.equal(statusString, '');
+
+    change.submittable = false;
+    statuses = changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Active']);
+
+    // With no missing labels but no submitEnabled option.
+    change.submittable = true;
+    statuses = changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Active']);
+
+    // Without missing labels and enabled submit
+    statuses = changeStatuses(change,
+        {includeDerived: true, submitEnabled: true});
+    assert.deepEqual(statuses, ['Ready to submit']);
+
+    change.mergeable = false;
+    change.submittable = true;
+    statuses = changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Merge Conflict']);
+
+    delete change.mergeable;
+    change.submittable = true;
+    statuses = changeStatuses(change,
+        {includeDerived: true, mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, ['Ready to submit']);
+
+    change.submittable = true;
+    statuses = 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 = changeStatuses(change);
+    const statusString = 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 = changeStatuses(change);
+    const statusString = 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 = changeStatuses(change);
+    const statusString = 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 = changeStatuses(change);
+    const statusString = 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 = changeStatuses(change);
+    const statusString = 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 = changeStatuses(change);
+    const statusString = changeStatusString(change);
+    assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
+    assert.equal(statusString, 'Merge Conflict, WIP, Private');
+  });
+
+  test('isRemovableReviewer', () => {
+    let change = {
+      removable_reviewers: [{_account_id: 1}],
+    };
+    const reviewer = {_account_id: 1};
+
+    assert.equal(isRemovableReviewer(change, reviewer), true);
+
+    change = {
+      removable_reviewers: [{_account_id: 2}],
+    };
+    assert.equal(isRemovableReviewer(change, reviewer), false);
+  });
+
+  test('changeIsOpen', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+      mergeable: false,
+    };
+    assert.isTrue(changeIsOpen(change));
+    change.status = 'MERGED';
+    assert.isFalse(changeIsOpen(change));
+  });
+
+  test('changeIsMerged', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'MERGED',
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+      mergeable: false,
+    };
+    assert.isTrue(changeIsMerged(change));
+    change.status = 'NEW';
+    assert.isFalse(changeIsMerged(change));
+  });
+
+  test('changeIsAbandoned', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'ABANDONED',
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+      mergeable: false,
+    };
+    assert.isTrue(changeIsAbandoned(change));
+    change.status = 'NEW';
+    assert.isFalse(changeIsAbandoned(change));
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
new file mode 100644
index 0000000..5f8aa82
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -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 {
+  CommentBasics,
+  CommentInfo,
+  PatchSetNum,
+  RobotCommentInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../types/common';
+import {CommentSide, Side} from '../constants/constants';
+import {parseDate} from './date-util';
+
+export interface DraftCommentProps {
+  __draft?: boolean;
+  __draftID?: string;
+  __date?: Date;
+}
+
+export type DraftInfo = CommentBasics & DraftCommentProps;
+
+/**
+ * Each of the type implements or extends CommentBasics.
+ */
+export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
+
+export interface UIStateCommentProps {
+  // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
+  // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
+  // but side being of type CommentSide. :-)
+  __commentSide?: Side;
+  // TODO(TS): Remove this. Seems to be exactly the same as `path`??
+  __path?: string;
+  collapsed?: boolean;
+  // TODO(TS): Consider allowing this only for drafts.
+  __editing?: boolean;
+  __otherEditing?: boolean;
+}
+
+export type UIDraft = DraftInfo & UIStateCommentProps;
+
+export type UIHuman = CommentInfo & UIStateCommentProps;
+
+export type UIRobot = RobotCommentInfo & UIStateCommentProps;
+
+export type UIComment = UIHuman | UIRobot | UIDraft;
+
+export type CommentMap = {[path: string]: boolean};
+
+export function isRobot<T extends CommentInfo>(
+  x: T | DraftInfo | RobotCommentInfo | undefined
+): x is RobotCommentInfo {
+  return !!x && !!(x as RobotCommentInfo).robot_id;
+}
+
+export function isDraft<T extends CommentInfo>(
+  x: T | UIDraft | undefined
+): x is UIDraft {
+  return !!x && !!(x as UIDraft).__draft;
+}
+
+interface SortableComment {
+  __draft?: boolean;
+  __date?: Date;
+  updated?: Timestamp;
+  id?: UrlEncodedCommentId;
+}
+
+export function sortComments<T extends SortableComment>(comments: T[]): T[] {
+  return comments.slice(0).sort((c1, c2) => {
+    const d1 = !!c1.__draft;
+    const d2 = !!c2.__draft;
+    if (d1 !== d2) return d1 ? 1 : -1;
+
+    const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
+    const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
+    const dateDiff = date1!.valueOf() - date2!.valueOf();
+    if (dateDiff !== 0) return dateDiff;
+
+    const id1 = c1.id ?? '';
+    const id2 = c2.id ?? '';
+    return id1.localeCompare(id2);
+  });
+}
+
+export interface CommentThread {
+  comments: UIComment[];
+  patchNum?: PatchSetNum;
+  path: string;
+  // TODO(TS): It would be nice to use LineNumber here, but the comment thread
+  // element actually relies on line to be undefined for file comments. Be
+  // aware of element attribute getters and setters, if you try to refactor
+  // this. :-) Still worthwhile to do ...
+  line?: number;
+  rootId: UrlEncodedCommentId;
+  commentSide?: CommentSide;
+}
+
+export function getLastComment(thread?: CommentThread): UIComment | undefined {
+  const len = thread?.comments.length;
+  return thread && len ? thread.comments[len - 1] : undefined;
+}
+
+export function isUnresolved(thread?: CommentThread): boolean {
+  return !!getLastComment(thread)?.unresolved;
+}
+
+export function isDraftThread(thread?: CommentThread): boolean {
+  return isDraft(getLastComment(thread));
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.js b/polygerrit-ui/app/utils/comment-util_test.js
new file mode 100644
index 0000000..ad19974
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util_test.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 '../test/common-test-setup-karma.js';
+import {
+  isUnresolved,
+} from './comment-util.js';
+
+suite('comment-util', () => {
+  test('isUnresolved', () => {
+    assert.isFalse(isUnresolved(undefined));
+    assert.isFalse(isUnresolved({comments: []}));
+    assert.isTrue(isUnresolved({comments: [{unresolved: true}]}));
+    assert.isFalse(isUnresolved({comments: [{unresolved: false}]}));
+    assert.isTrue(isUnresolved(
+        {comments: [{unresolved: false}, {unresolved: true}]}));
+    assert.isFalse(isUnresolved(
+        {comments: [{unresolved: true}, {unresolved: false}]}));
+  });
+});
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
new file mode 100644
index 0000000..5b332ea
--- /dev/null
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -0,0 +1,69 @@
+/**
+ * @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 Functions in this file contains some widely used
+ * code patterns. If you noticed a repeated code and none of the existing util
+ * files are appropriate for it - you can wrap the code in a function and put it
+ * here. If you notice that several functions can be group together - create
+ * a separate util file for them.
+ */
+
+/**
+ * Wrapper for the Object.prototype.hasOwnProperty method
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function hasOwnProperty(obj: any, prop: PropertyKey) {
+  // Typescript rules don't allow to use obj.hasOwnProperty directly
+  return Object.prototype.hasOwnProperty.call(obj, prop);
+}
+
+// TODO(TS): move to common types once we have type utils
+//  Required for constructor signature.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Constructor<T> = new (...args: any[]) => T;
+
+/**
+ * Use the function for compile-time checking that all possible input
+ * values are processed
+ */
+export function assertNever(obj: never, msg: string): never {
+  console.error(msg, obj);
+  throw new Error(msg);
+}
+
+/**
+ * Returns true, if both sets contain the same members.
+ */
+export function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
+  if (a.size !== b.size) {
+    return false;
+  }
+  return containsAll(a, b);
+}
+
+/**
+ * Returns true, if 'set' contains 'subset'.
+ */
+export function containsAll<T>(set: Set<T>, subSet: Set<T>): boolean {
+  for (const value of subSet) {
+    if (!set.has(value)) {
+      return false;
+    }
+  }
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
new file mode 100644
index 0000000..917d652b
--- /dev/null
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -0,0 +1,69 @@
+/**
+ * @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 '../test/common-test-setup-karma.js';
+import {hasOwnProperty, areSetsEqual, containsAll} from './common-util.js';
+
+suite('common-util tests', () => {
+  suite('hasOwnProperty', () => {
+    test('object with the default prototype', () => {
+      const obj = {
+        'abc': 3,
+        'name with spaces': 5,
+      };
+      assert.isTrue(hasOwnProperty(obj, 'abc'));
+      assert.isTrue(hasOwnProperty(obj, 'name with spaces'));
+      assert.isFalse(hasOwnProperty(obj, 'def'));
+    });
+    test('object prototype has overriden hasOwnProperty', () => {
+      const F = function() {
+        this.abc = 23;
+      };
+      F.prototype.hasOwnProperty = function(key) {
+        return true;
+      };
+      const obj = new F();
+      assert.isTrue(hasOwnProperty(obj, 'abc'));
+      assert.isFalse(hasOwnProperty(obj, 'def'));
+    });
+  });
+
+  test('areSetsEqual', () => {
+    assert.isTrue(areSetsEqual(new Set(), new Set()));
+    assert.isTrue(areSetsEqual(new Set([1]), new Set([1])));
+    assert.isTrue(areSetsEqual(new Set([1, 1, 1, 1]), new Set([1])));
+    assert.isTrue(areSetsEqual(new Set([1, 1, 2, 2]), new Set([2, 1, 2, 1])));
+    assert.isTrue(areSetsEqual(new Set([1, 2, 3, 4]), new Set([4, 3, 2, 1])));
+    assert.isFalse(areSetsEqual(new Set(), new Set([1])));
+    assert.isFalse(areSetsEqual(new Set([1]), new Set([2])));
+    assert.isFalse(areSetsEqual(new Set([1, 2, 4]), new Set([1, 2, 3])));
+  });
+
+  test('containsAll', () => {
+    assert.isTrue(containsAll(new Set(), new Set()));
+    assert.isTrue(containsAll(new Set([1]), new Set()));
+    assert.isTrue(containsAll(new Set([1]), new Set([1])));
+    assert.isTrue(containsAll(new Set([1, 2]), new Set([1])));
+    assert.isTrue(containsAll(new Set([1, 2]), new Set([2])));
+    assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 4])));
+    assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 4])));
+    assert.isFalse(containsAll(new Set(), new Set([2])));
+    assert.isFalse(containsAll(new Set([1]), new Set([2])));
+    assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
+    assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
+  });
+});
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
new file mode 100644
index 0000000..1dd2d2f
--- /dev/null
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -0,0 +1,214 @@
+import {Timestamp} from '../types/common';
+
+/**
+ * @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 Duration = {
+  HOUR: 1000 * 60 * 60,
+  DAY: 1000 * 60 * 60 * 24,
+};
+
+export function parseDate(dateStr: Timestamp) {
+  // 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');
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function isValidDate(date: any): date is Date {
+  return date instanceof Date && !isNaN(date.valueOf());
+}
+
+// similar to fromNow from moment.js
+export function fromNow(date: Date, noAgo = false) {
+  const now = new Date();
+  const ago = noAgo ? '' : ' ago';
+  const secondsAgo = Math.round((now.valueOf() - date.valueOf()) / 1000);
+  if (secondsAgo <= 59) return 'just now';
+  if (secondsAgo <= 119) return `1 minute${ago}`;
+  const minutesAgo = Math.round(secondsAgo / 60);
+  if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
+  if (minutesAgo === 60) return `1 hour${ago}`;
+  if (minutesAgo <= 119) return `1 hour ${minutesAgo - 60} min${ago}`;
+  const hoursAgo = Math.round(minutesAgo / 60);
+  if (hoursAgo <= 23) return `${hoursAgo} hours${ago}`;
+  if (hoursAgo === 24) return `1 day${ago}`;
+  if (hoursAgo <= 47) return `1 day ${hoursAgo - 24} hr${ago}`;
+  const daysAgo = Math.round(hoursAgo / 24);
+  if (daysAgo <= 30) return `${daysAgo} days${ago}`;
+  if (daysAgo <= 60) return `1 month${ago}`;
+  const monthsAgo = Math.round(daysAgo / 30);
+  if (monthsAgo <= 11) return `${monthsAgo} months${ago}`;
+  if (monthsAgo === 12) return `1 year${ago}`;
+  if (monthsAgo <= 24) return `1 year ${monthsAgo - 12} m${ago}`;
+  const yearsAgo = Math.round(daysAgo / 365);
+  return `${yearsAgo} years${ago}`;
+}
+
+/**
+ * Return true if date is within 24 hours and on the same day.
+ */
+export function isWithinDay(now: Date, date: Date) {
+  const diff = now.valueOf() - date.valueOf();
+  return diff < Duration.DAY && date.getDay() === now.getDay();
+}
+
+/**
+ * Returns true if date is from one to six months.
+ */
+export function isWithinHalfYear(now: Date, date: Date) {
+  const diff = now.valueOf() - date.valueOf();
+  return diff < 180 * Duration.DAY;
+}
+interface Options {
+  month?: string;
+  year?: string;
+  day?: string;
+  hour?: string;
+  hour12?: boolean;
+  minute?: string;
+  second?: string;
+}
+
+// TODO(dmfilippov): TS-Fix review this type. All fields here must be optional,
+// but this require some changes in the code. During JS->TS migration
+// we want to avoid code changes where possible, so for simplicity we
+// define it with almost all fields mandatory
+interface DateTimeFormatParts {
+  year: string;
+  month: string;
+  day: string;
+  hour: string;
+  minute: string;
+  second: string;
+  dayPeriod: string;
+  dayperiod?: string;
+  // Object can have other properties, but our code doesn't use it
+  [key: string]: string | undefined;
+}
+
+export function formatDate(date: Date, format: string) {
+  const options: Options = {};
+  if (format.includes('MM')) {
+    if (format.includes('MMM')) {
+      options.month = 'short';
+    } else {
+      options.month = '2-digit';
+    }
+  }
+  if (format.includes('YY')) {
+    if (format.includes('YYYY')) {
+      options.year = 'numeric';
+    } else {
+      options.year = '2-digit';
+    }
+  }
+
+  if (format.includes('DD')) {
+    options.day = '2-digit';
+  }
+
+  if (format.includes('HH')) {
+    options.hour = '2-digit';
+    options.hour12 = false;
+  }
+
+  if (format.includes('h')) {
+    options.hour = 'numeric';
+    options.hour12 = true;
+  }
+
+  if (format.includes('mm')) {
+    options.minute = '2-digit';
+  }
+
+  if (format.includes('ss')) {
+    options.second = '2-digit';
+  }
+
+  let locale = 'en-US';
+  // Workaround for Chrome 80, en-US is using h24 (midnight is 24:00),
+  // en-GB is using h23 (midnight is 00:00)
+  if (format.includes('HH')) {
+    locale = 'en-GB';
+  }
+
+  const dtf = new Intl.DateTimeFormat(locale, options);
+  const parts = dtf
+    .formatToParts(date)
+    .filter(o => o.type !== 'literal')
+    .reduce((acc, o: Intl.DateTimeFormatPart) => {
+      acc[o.type] = o.value;
+      return acc;
+    }, {} as DateTimeFormatParts);
+  if (format.includes('YY')) {
+    if (format.includes('YYYY')) {
+      format = format.replace('YYYY', parts.year);
+    } else {
+      format = format.replace('YY', parts.year);
+    }
+  }
+
+  if (format.includes('DD')) {
+    format = format.replace('DD', parts.day);
+  }
+
+  if (format.includes('HH')) {
+    format = format.replace('HH', parts.hour);
+  }
+
+  if (format.includes('h')) {
+    format = format.replace('h', parts.hour);
+  }
+
+  if (format.includes('mm')) {
+    format = format.replace('mm', parts.minute);
+  }
+
+  if (format.includes('ss')) {
+    format = format.replace('ss', parts.second);
+  }
+
+  if (format.includes('A')) {
+    if (parts.dayperiod) {
+      // Workaround for chrome 70 and below
+      format = format.replace('A', parts.dayperiod.toUpperCase());
+    } else {
+      format = format.replace('A', parts.dayPeriod.toUpperCase());
+    }
+  }
+  if (format.includes('MM')) {
+    if (format.includes('MMM')) {
+      format = format.replace('MMM', parts.month);
+    } else {
+      format = format.replace('MM', parts.month);
+    }
+  }
+  return format;
+}
+
+export function utcOffsetString() {
+  const now = new Date();
+  const tzo = -now.getTimezoneOffset();
+  const pad = (num: number) => {
+    const norm = Math.floor(Math.abs(num));
+    return (norm < 10 ? '0' : '') + norm.toString();
+  };
+  return ` UTC${tzo >= 0 ? '+' : '-'}${pad(tzo / 60)}:${pad(tzo % 60)}`;
+}
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
new file mode 100644
index 0000000..a003c65
--- /dev/null
+++ b/polygerrit-ui/app/utils/date-util_test.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 '../test/common-test-setup-karma.js';
+import {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate} from './date-util.js';
+
+suite('date-util tests', () => {
+  suite('parseDate', () => {
+    test('parseDate server date', () => {
+      const parsed = parseDate('2015-09-15 20:34:00.000000000');
+      assert.equal('2015-09-15T20:34:00.000Z', parsed.toISOString());
+    });
+  });
+
+  suite('isValidDate', () => {
+    test('date is valid', () => {
+      assert.isTrue(isValidDate(new Date()));
+    });
+    test('broken date is invalid', () => {
+      assert.isFalse(isValidDate(new Date('xxx')));
+    });
+  });
+
+  suite('fromNow', () => {
+    test('test all variants', () => {
+      const fakeNow = new Date('May 08 2020 12:00:00');
+      sinon.useFakeTimers(fakeNow.getTime());
+      assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
+      assert.equal('1 minute ago', fromNow(new Date('May 08 2020 11:59:00')));
+      assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
+      assert.equal('1 hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+      assert.equal(
+          '1 hour 5 min ago', fromNow(new Date('May 08 2020 10:55:00')));
+      assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
+      assert.equal('1 day ago', fromNow(new Date('May 07 2020 12:00:00')));
+      assert.equal('1 day 2 hr ago', fromNow(new Date('May 07 2020 10:00:00')));
+      assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
+      assert.equal('1 month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
+      assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
+      assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
+      assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
+    });
+  });
+
+  suite('isWithinDay', () => {
+    test('basics works', () => {
+      assert.isTrue(isWithinDay(new Date('May 08 2020 12:00:00'),
+          new Date('May 08 2020 02:00:00')));
+      assert.isFalse(isWithinDay(new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 12:00:00')));
+    });
+  });
+
+  suite('isWithinHalfYear', () => {
+    test('basics works', () => {
+      assert.isTrue(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
+          new Date('Feb 08 2020 12:00:00')));
+      assert.isFalse(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
+          new Date('Nov 07 2019 12:00:00')));
+    });
+  });
+
+  suite('formatDate', () => {
+    test('works for standard format', () => {
+      const stdFormat = 'MMM DD, YYYY';
+      assert.equal('May 08, 2020',
+          formatDate(new Date('May 08 2020 12:00:00'), stdFormat));
+      assert.equal('Feb 28, 2020',
+          formatDate(new Date('Feb 28 2020 12:00:00'), stdFormat));
+
+      const time24Format = 'HH:mm:ss';
+      assert.equal('Feb 28, 2020 12:01:12',
+          formatDate(new Date('Feb 28 2020 12:01:12'), stdFormat + ' '
+          + time24Format));
+    });
+    test('works for euro format', () => {
+      const euroFormat = 'DD.MM.YYYY';
+      assert.equal('01.12.2019',
+          formatDate(new Date('Dec 01 2019 12:00:00'), euroFormat));
+      assert.equal('20.01.2002',
+          formatDate(new Date('Jan 20 2002 12:00:00'), euroFormat));
+
+      const time24Format = 'HH:mm:ss';
+      assert.equal('28.02.2020 00:01:12',
+          formatDate(new Date('Feb 28 2020 00:01:12'), euroFormat + ' '
+          + time24Format));
+    });
+    test('works for iso format', () => {
+      const isoFormat = 'YYYY-MM-DD';
+      assert.equal('2015-01-01',
+          formatDate(new Date('Jan 01 2015 12:00:00'), isoFormat));
+      assert.equal('2013-07-03',
+          formatDate(new Date('Jul 03 2013 12:00:00'), isoFormat));
+
+      const timeFormat = 'h:mm:ss A';
+      assert.equal('2013-07-03 5:00:00 AM',
+          formatDate(new Date('Jul 03 2013 05:00:00'), isoFormat + ' '
+          + timeFormat));
+      assert.equal('2013-07-03 5:00:00 PM',
+          formatDate(new Date('Jul 03 2013 17:00:00'), isoFormat + ' '
+          + timeFormat));
+    });
+    test('h:mm:ss A shows correctly midnight and midday', () => {
+      const timeFormat = 'h:mm A';
+      assert.equal('12:14 PM',
+          formatDate(new Date('Jul 03 2013 12:14:00'), timeFormat));
+      assert.equal('12:15 AM',
+          formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
new file mode 100644
index 0000000..7114f98
--- /dev/null
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -0,0 +1,88 @@
+/**
+ * @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 {AccountInfo, GroupInfo, ServerInfo} from '../types/common';
+import {DefaultDisplayNameConfig} from '../constants/constants';
+
+const ANONYMOUS_NAME = 'Anonymous';
+
+export function getUserName(
+  config?: ServerInfo,
+  account?: AccountInfo
+): string {
+  if (account?.name) {
+    return account.name;
+  } else if (account?.username) {
+    return account.username;
+  } else if (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;
+}
+
+export function getDisplayName(
+  config?: ServerInfo,
+  account?: AccountInfo,
+  firstNameOnly = false
+): string {
+  if (account?.display_name) {
+    return account.display_name;
+  }
+  if (!account || !account.name) {
+    return getUserName(config, account);
+  }
+  const configDefault = config?.accounts?.default_display_name;
+  if (firstNameOnly || configDefault === DefaultDisplayNameConfig.FIRST_NAME) {
+    return account.name.trim().split(' ')[0];
+  }
+  if (configDefault === DefaultDisplayNameConfig.USERNAME && account.username) {
+    return account.username;
+  }
+  // Treat every other value as FULL_NAME.
+  return account.name;
+}
+
+export function getAccountDisplayName(
+  config: ServerInfo | undefined,
+  account: AccountInfo
+) {
+  const reviewerName = getUserName(config, account);
+  const reviewerEmail = _accountEmail(account.email);
+  const reviewerStatus = account.status ? '(' + account.status + ')' : '';
+  return [reviewerName, reviewerEmail, reviewerStatus]
+    .filter(p => p.length > 0)
+    .join(' ');
+}
+
+function _accountEmail(email?: string) {
+  if (typeof email !== 'undefined') {
+    return '<' + email + '>';
+  }
+  return '';
+}
+
+export const _testOnly_accountEmail = _accountEmail;
+
+export function getGroupDisplayName(group: GroupInfo) {
+  return `${group.name || ''} (group)`;
+}
diff --git a/polygerrit-ui/app/utils/display-name-util_test.js b/polygerrit-ui/app/utils/display-name-util_test.js
new file mode 100644
index 0000000..9bb68dc
--- /dev/null
+++ b/polygerrit-ui/app/utils/display-name-util_test.js
@@ -0,0 +1,200 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {getDisplayName, getUserName, getGroupDisplayName, getAccountDisplayName, _testOnly_accountEmail} from './display-name-util.js';
+
+suite('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(getDisplayName(config, account),
+        'test-name');
+  });
+
+  test('getDisplayName prefer displayName', () => {
+    const account = {
+      name: 'test-name',
+      display_name: 'better-name',
+    };
+    assert.equal(getDisplayName(config, account),
+        'better-name');
+  });
+
+  test('getDisplayName prefer username default', () => {
+    const account = {
+      name: 'test-name',
+      username: 'user-name',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'USERNAME',
+      },
+    };
+    assert.equal(getDisplayName(config, account),
+        'user-name');
+  });
+
+  test('getDisplayName firstNameOnly', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    assert.equal(getDisplayName(config, account, true), 'firstname');
+  });
+
+  test('getDisplayName prefer first name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'FIRST_NAME',
+      },
+    };
+    assert.equal(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(getDisplayName(config, account),
+        'firstname');
+  });
+
+  test('getDisplayName full name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'FULL_NAME',
+      },
+    };
+    assert.equal(getDisplayName(config, account),
+        'firstname lastname');
+  });
+
+  test('getDisplayName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.deepEqual(getUserName(config, account),
+        'test-name');
+  });
+
+  test('getUserName username only', () => {
+    const account = {
+      username: 'test-user',
+    };
+    assert.deepEqual(getUserName(config, account),
+        'test-user');
+  });
+
+  test('getUserName email only', () => {
+    const account = {
+      email: 'test-user@test-url.com',
+    };
+    assert.deepEqual(getUserName(config, account),
+        'test-user@test-url.com');
+  });
+
+  test('getUserName returns not Anonymous Coward as the anon name', () => {
+    assert.deepEqual(getUserName(config, null),
+        'Anonymous');
+  });
+
+  test('getUserName for the config returning the anon name', () => {
+    const config = {
+      user: {
+        anonymous_coward_name: 'Test Anon',
+      },
+    };
+    assert.deepEqual(getUserName(config, null),
+        'Test Anon');
+  });
+
+  test('getAccountDisplayName - account with name only', () => {
+    assert.equal(
+        getAccountDisplayName(config,
+            {name: 'Some user name'}),
+        'Some user name');
+  });
+
+  test('getAccountDisplayName - account with email only', () => {
+    assert.equal(
+        getAccountDisplayName(config,
+            {email: 'my@example.com'}),
+        'my@example.com <my@example.com>');
+  });
+
+  test('getAccountDisplayName - account with name and status', () => {
+    assert.equal(
+        getAccountDisplayName(config, {
+          name: 'Some name',
+          status: 'OOO',
+        }),
+        'Some name (OOO)');
+  });
+
+  test('getAccountDisplayName - account with name and email', () => {
+    assert.equal(
+        getAccountDisplayName(config, {
+          name: 'Some name',
+          email: 'my@example.com',
+        }),
+        'Some name <my@example.com>');
+  });
+
+  test('getAccountDisplayName - account with name, email and status', () => {
+    assert.equal(
+        getAccountDisplayName(config, {
+          name: 'Some name',
+          email: 'my@example.com',
+          status: 'OOO',
+        }),
+        'Some name <my@example.com> (OOO)');
+  });
+
+  test('getGroupDisplayName', () => {
+    assert.equal(
+        getGroupDisplayName({name: 'Some user name'}),
+        'Some user name (group)');
+  });
+
+  test('_accountEmail', () => {
+    assert.equal(
+        _testOnly_accountEmail('email@gerritreview.com'),
+        '<email@gerritreview.com>');
+    assert.equal(_testOnly_accountEmail(undefined), '');
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
new file mode 100644
index 0000000..c8920b2
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -0,0 +1,292 @@
+/**
+ * @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 {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
+
+/**
+ * Event emitted from polymer elements.
+ */
+export interface PolymerEvent extends EventApi, Event {}
+
+interface ElementWithShadowRoot extends Element {
+  shadowRoot: ShadowRoot;
+}
+
+/**
+ * Type guard for element with a shadowRoot.
+ */
+function isElementWithShadowRoot(
+  el: Element | ShadowRoot
+): el is ElementWithShadowRoot {
+  return 'shadowRoot' in el;
+}
+
+// TODO: maybe should have a better name for this
+function getPathFromNode(el: EventTarget) {
+  let tagName = '';
+  let id = '';
+  let className = '';
+  if (el instanceof Element) {
+    tagName = el.tagName;
+    id = el.id;
+    className = el.className;
+  }
+  if (
+    !tagName ||
+    'GR-APP' === tagName ||
+    el instanceof DocumentFragment ||
+    el instanceof HTMLSlotElement
+  ) {
+    return '';
+  }
+  let path = '';
+  if (tagName) {
+    path += tagName.toLowerCase();
+  }
+  if (id) {
+    path += `#${id}`;
+  }
+  if (className) {
+    path += `.${className.replace(/ /g, '.')}`;
+  }
+  return path;
+}
+
+/**
+ * Get computed style value.
+ *
+ * If ShadyCSS is provided, use ShadyCSS api.
+ * If `getComputedStyleValue` is provided on the element, use it.
+ * Otherwise fallback to native method (in polymer 2).
+ *
+ */
+export function getComputedStyleValue(
+  name: string,
+  el: Element | LegacyElementMixin
+) {
+  let style;
+  if (window.ShadyCSS) {
+    style = window.ShadyCSS.getComputedStyleValue(el as Element, name);
+    // `getComputedStyleValue` defined through LegacyElementMixin
+    // TODO: It should be safe to just use `getComputedStyle`, but just to be safe
+  } else if ('getComputedStyleValue' in el) {
+    style = el.getComputedStyleValue(name);
+  } else {
+    style = getComputedStyle(el).getPropertyValue(name);
+  }
+  return style;
+}
+
+/**
+ * Query selector on a dom element.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ */
+export function querySelector(
+  el: Element | ShadowRoot,
+  selector: string
+): Element | null {
+  let nodes = [el];
+  let result = null;
+  while (nodes.length) {
+    const node = nodes.pop();
+
+    // Skip if it's an invalid node.
+    if (!node || !node.querySelector) continue;
+
+    // Try find it with native querySelector directly
+    result = node.querySelector(selector);
+
+    if (result) {
+      break;
+    }
+
+    // Add all nodes with shadowRoot and loop through
+    const allShadowNodes = [...node.querySelectorAll('*')]
+      .filter(isElementWithShadowRoot)
+      .map(child => child.shadowRoot);
+    nodes = nodes.concat(allShadowNodes);
+
+    // Add shadowRoot of current node if has one
+    // as its not included in node.querySelectorAll('*')
+    if (isElementWithShadowRoot(node)) {
+      nodes.push(node.shadowRoot);
+    }
+  }
+  return result;
+}
+
+/**
+ * Query selector all dom elements matching with certain selector.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ * Note: this can be very expensive, only use when have to.
+ */
+export function querySelectorAll(
+  el: Element | ShadowRoot,
+  selector: string
+): Element[] {
+  let nodes = [el];
+  const results = new Set<Element>();
+  while (nodes.length) {
+    const node = nodes.pop();
+
+    if (!node || !node.querySelectorAll) continue;
+
+    // Try find all from regular children
+    [...node.querySelectorAll(selector)].forEach(el => results.add(el));
+
+    // Add all nodes with shadowRoot and loop through
+    const allShadowNodes = [...node.querySelectorAll('*')]
+      .filter(isElementWithShadowRoot)
+      .map(child => child.shadowRoot);
+    nodes = nodes.concat(allShadowNodes);
+
+    // Add shadowRoot of current node if has one
+    // as its not included in node.querySelectorAll('*')
+    if (isElementWithShadowRoot(node)) {
+      nodes.push(node.shadowRoot);
+    }
+  }
+  return [...results];
+}
+
+/**
+ * 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.
+ *
+ * domNode.onclick = e => {
+ *  getEventPath(e); // eg: div.class1>p#pid.class2
+ * }
+ */
+export function getEventPath<T extends PolymerEvent>(e?: T) {
+  if (!e) return '';
+
+  let path = e.composedPath();
+  if (!path || !path.length) {
+    path = [];
+    let el = e.target;
+    while (el) {
+      path.push(el);
+      el = (el as Node).parentNode || (el as ShadowRoot).host;
+    }
+  }
+
+  return path.reduce<string>((domPath: string, curEl: EventTarget) => {
+    const pathForEl = getPathFromNode(curEl);
+    if (!pathForEl) return domPath;
+    return domPath ? `${pathForEl}>${domPath}` : pathForEl;
+  }, '');
+}
+
+/**
+ * Are any ancestors of the element (or the element itself) members of the
+ * given class.
+ *
+ */
+export function descendedFromClass(
+  element: Element,
+  className: string,
+  stopElement?: Element
+) {
+  let isDescendant = element.classList.contains(className);
+  while (
+    !isDescendant &&
+    element.parentElement &&
+    (!stopElement || element.parentElement !== stopElement)
+  ) {
+    isDescendant = element.classList.contains(className);
+    element = element.parentElement;
+  }
+  return isDescendant;
+}
+
+/**
+ * Convert any string into a valid class name.
+ *
+ * For class names, naming rules:
+ * Must begin with a letter A-Z or a-z
+ * Can be followed by: letters (A-Za-z), digits (0-9), hyphens ("-"), and underscores ("_")
+ */
+export function strToClassName(str = '', prefix = 'generated_') {
+  return `${prefix}${str.replace(/[^a-zA-Z0-9-_]/g, '_')}`;
+}
+
+// shared API element
+// TODO: Make this a proper service singleton. Move into AppContext?
+let _sharedApiEl: JsApiService;
+
+/**
+ * Retrieves the shared API element.
+ * We want to keep a single instance of API element instead of
+ * creating multiple elements.
+ */
+export function getSharedApiEl(): JsApiService {
+  if (!_sharedApiEl) {
+    _sharedApiEl = (document.createElement(
+      'gr-js-api-interface'
+    ) as unknown) as JsApiService;
+  }
+  return _sharedApiEl;
+}
+
+// document.activeElement is not enough, because it's not getting activeElement
+// without looking inside of shadow roots. This will find best activeElement.
+export function findActiveElement(
+  root: DocumentOrShadowRoot | null,
+  ignoreDialogs?: boolean
+): HTMLElement | null {
+  if (root === null) {
+    return null;
+  }
+  if (
+    ignoreDialogs &&
+    root.activeElement &&
+    root.activeElement.nodeName.toUpperCase().includes('DIALOG')
+  ) {
+    return null;
+  }
+  if (root.activeElement?.shadowRoot?.activeElement) {
+    return findActiveElement(root.activeElement.shadowRoot);
+  }
+  if (!root.activeElement) {
+    return null;
+  }
+  // We block some elements
+  if ('BODY' === root.activeElement.nodeName.toUpperCase()) {
+    return null;
+  }
+  return root.activeElement as HTMLElement;
+}
+
+// Whether the browser is Safari. Used for polyfilling unique browser behavior.
+export function isSafari() {
+  return (
+    /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
+    (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
+  );
+}
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
new file mode 100644
index 0000000..bcb4505
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util_test.js
@@ -0,0 +1,155 @@
+/**
+ * @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 '../test/common-test-setup-karma.js';
+import {strToClassName, getComputedStyleValue, querySelector, querySelectorAll, descendedFromClass, getEventPath} from './dom-util.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+class TestEle extends PolymerElement {
+  static get is() {
+    return 'dom-util-test-element';
+  }
+
+  static get template() {
+    return html`
+    <div>
+      <div class="a">
+        <div class="b">
+          <div class="c"></div>
+        </div>
+        <span class="ss"></span>
+      </div>
+      <span class="ss"></span>
+    </div>
+    `;
+  }
+}
+
+customElements.define(TestEle.is, TestEle);
+
+const basicFixture = fixtureFromTemplate(html`
+  <div id="test" class="a b c">
+    <a class="testBtn" style="color:red;"></a>
+    <dom-util-test-element></dom-util-test-element>
+    <span class="ss"></span>
+  </div>
+`);
+
+suite('dom-util tests', () => {
+  suite('getEventPath', () => {
+    test('empty event', () => {
+      assert.equal(getEventPath(), '');
+      assert.equal(getEventPath(null), '');
+      assert.equal(getEventPath(undefined), '');
+      assert.equal(getEventPath({composedPath: () => []}), '');
+    });
+
+    test('event with fake path', () => {
+      assert.equal(getEventPath({composedPath: () => []}), '');
+      const dd = document.createElement('dd');
+      assert.equal(getEventPath({composedPath: () => [dd]}), 'dd');
+    });
+
+    test('event with fake complicated path', () => {
+      const dd = document.createElement('dd');
+      dd.setAttribute('id', 'test');
+      dd.className = 'a b';
+      const divNode = document.createElement('DIV');
+      divNode.id = 'test2';
+      divNode.className = 'a b c';
+      assert.equal(getEventPath(
+          {composedPath: () => [dd, divNode]}),
+      'div#test2.a.b.c>dd#test.a.b'
+      );
+    });
+
+    test('event with fake target', () => {
+      const fakeTargetParent1 = document.createElement('dd');
+      fakeTargetParent1.setAttribute('id', 'test');
+      fakeTargetParent1.className = 'a b';
+      const fakeTargetParent2 = document.createElement('DIV');
+      fakeTargetParent2.id = 'test2';
+      fakeTargetParent2.className = 'a b c';
+      fakeTargetParent2.appendChild(fakeTargetParent1);
+      const fakeTarget = document.createElement('SPAN');
+      fakeTargetParent1.appendChild(fakeTarget);
+      assert.equal(
+          getEventPath({composedPath: () => {}, target: fakeTarget}),
+          'div#test2.a.b.c>dd#test.a.b>span'
+      );
+    });
+
+    test('event with real click', () => {
+      const element = basicFixture.instantiate();
+      const aLink = element.querySelector('a');
+      let path;
+      aLink.onclick = e => path = getEventPath(e);
+      MockInteractions.click(aLink);
+      assert.equal(
+          path,
+          `html>body>test-fixture#${basicFixture.fixtureId}>` +
+          'div#test.a.b.c>a.testBtn'
+      );
+    });
+  });
+
+  suite('querySelector and querySelectorAll', () => {
+    test('query cross shadow dom', () => {
+      const element = basicFixture.instantiate();
+      const theFirstEl = querySelector(element, '.ss');
+      const allEls = querySelectorAll(element, '.ss');
+      assert.equal(allEls.length, 3);
+      assert.equal(theFirstEl, allEls[0]);
+    });
+  });
+
+  suite('getComputedStyleValue', () => {
+    test('color style', () => {
+      const element = basicFixture.instantiate();
+      const testBtn = querySelector(element, '.testBtn');
+      assert.equal(
+          getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)'
+      );
+    });
+  });
+
+  suite('descendedFromClass', () => {
+    test('basic tests', () => {
+      const element = basicFixture.instantiate();
+      const testEl = querySelector(element, 'dom-util-test-element');
+      // .c is a child of .a and not vice versa.
+      assert.isTrue(descendedFromClass(querySelector(testEl, '.c'), 'a'));
+      assert.isFalse(descendedFromClass(querySelector(testEl, '.a'), 'c'));
+
+      // Stops at stop element.
+      assert.isFalse(descendedFromClass(querySelector(testEl, '.c'), 'a',
+          querySelector(testEl, '.b')));
+    });
+  });
+
+  suite('strToClassName', () => {
+    test('basic tests', () => {
+      assert.equal(strToClassName(''), 'generated_');
+      assert.equal(strToClassName('11'), 'generated_11');
+      assert.equal(strToClassName('0.123'), 'generated_0_123');
+      assert.equal(strToClassName('0.123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0>123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0<123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0+1+23', 'prefix_'), 'prefix_0_1_23');
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/inner-html-util.ts b/polygerrit-ui/app/utils/inner-html-util.ts
new file mode 100644
index 0000000..549f493
--- /dev/null
+++ b/polygerrit-ui/app/utils/inner-html-util.ts
@@ -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.
+ */
+
+// This file adds some simple checks to match internal google rules.
+// Internally in google it has different implementation
+
+import {BrandType} from '../types/common';
+
+export type SafeHtml = BrandType<string, '_safeHtml'>;
+export type SafeStyleSheet = BrandType<string, '_safeHtml'>;
+
+export function setInnerHtml(el: HTMLElement, innerHTML: SafeHtml) {
+  el.innerHTML = innerHTML;
+}
+
+export function createStyle(styleSheet: SafeStyleSheet): SafeHtml {
+  return `<style>${styleSheet}</style>` as SafeHtml;
+}
+
+export function safeStyleSheet(
+  templateObj: TemplateStringsArray
+): SafeStyleSheet {
+  const styleSheet = templateObj[0];
+  if (/[<>]/.test(styleSheet)) {
+    throw new Error('Forbidden characters in styleSheet string: ' + styleSheet);
+  }
+  return styleSheet as SafeStyleSheet;
+}
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
new file mode 100644
index 0000000..4313745
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -0,0 +1,44 @@
+/**
+ * @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 {
+  ApprovalInfo,
+  isDetailedLabelInfo,
+  LabelInfo,
+  VotingRangeInfo,
+} from '../types/common';
+
+// Name of the standard Code-Review label.
+export const CODE_REVIEW = 'Code-Review';
+
+export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined {
+  if (!label || !isDetailedLabelInfo(label)) return undefined;
+  const values = Object.keys(label.values).map(v => Number(v));
+  values.sort((a, b) => a - b);
+  if (!values.length) return undefined;
+  return {min: values[0], max: values[values.length - 1]};
+}
+
+export function getVotingRangeOrDefault(label?: LabelInfo): VotingRangeInfo {
+  const range = getVotingRange(label);
+  return range ? range : {min: 0, max: 0};
+}
+
+export function getMaxAccounts(label?: LabelInfo): ApprovalInfo[] {
+  if (!label || !isDetailedLabelInfo(label) || !label.all) return [];
+  const votingRange = getVotingRangeOrDefault(label);
+  return label.all.filter(account => account.value === votingRange.max);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
new file mode 100644
index 0000000..d6f7b3e
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util_test.js
@@ -0,0 +1,90 @@
+/**
+ * @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 '../test/common-test-setup-karma.js';
+import {
+  getVotingRange,
+  getVotingRangeOrDefault,
+  getMaxAccounts,
+} from './label-util.js';
+
+const VALUES_1 = {
+  '-1': 'bad',
+  '0': 'neutral',
+  '+1': 'good',
+};
+
+const VALUES_2 = {
+  '-1': 'bad',
+  '+2': 'perfect',
+  '0': 'neutral',
+  '-2': 'blocking',
+  '+1': 'good',
+};
+
+suite('label-util', () => {
+  test('getVotingRange -1 to +1', () => {
+    const label = {values: VALUES_1};
+    const expectedRange = {min: -1, max: 1};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange -2 to +2', () => {
+    const label = {values: VALUES_2};
+    const expectedRange = {min: -2, max: 2};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange empty values', () => {
+    const label = {
+      values: {},
+    };
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange no values', () => {
+    const label = {};
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getMaxAccounts', () => {
+    const label = {
+      values: VALUES_2,
+      all: [
+        {value: 2, _account_id: 314},
+        {value: 1, _account_id: 777},
+      ],
+    };
+
+    const maxAccounts = getMaxAccounts(label);
+
+    assert.equal(maxAccounts.length, 1);
+    assert.equal(maxAccounts[0]._account_id, 314);
+  });
+
+  test('getMaxAccounts unset parameters', () => {
+    assert.isEmpty(getMaxAccounts());
+    assert.isEmpty(getMaxAccounts({}));
+    assert.isEmpty(getMaxAccounts({values: VALUES_2}));
+  });
+});
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
new file mode 100644
index 0000000..19cfe48
--- /dev/null
+++ b/polygerrit-ui/app/utils/page-wrapper-utils.ts
@@ -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 'page/page';
+
+// Reexport page.js. To make it work, karma, server.go and rollup patch
+// page.js and replace "this" to "window". Otherwise, it can't assign global
+// property. We can't import page.mjs because typescript doesn't support mjs
+// extensions
+export interface Page {
+  (pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
+  (pageCallback: PageCallback): void;
+  show(url: string): void;
+  redirect(url: string): void;
+  base(url: string): void;
+  start(): void;
+  exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
+}
+
+// See https://visionmedia.github.io/page.js/ for details
+export interface PageContext {
+  save(): void;
+  handled: boolean;
+  canonicalPath: string;
+  path: string;
+  querystring: string;
+  pathname: string;
+  state: unknown;
+  title: string;
+  hash: string;
+  params: {[paramIndex: string]: string};
+}
+
+export type PageNextCallback = () => void;
+
+export type PageCallback = (
+  context: PageContext,
+  next: PageNextCallback
+) => void;
+
+export const page = window['page'] as Page;
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
new file mode 100644
index 0000000..8974af8
--- /dev/null
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -0,0 +1,346 @@
+import {
+  RevisionInfo,
+  ChangeInfo,
+  PatchSetNum,
+  EditPatchSetNum,
+  BrandType,
+  ParentPatchSetNum,
+} from '../types/common';
+import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+/**
+ * @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'];
+
+// TODO(TS): Replace usages of these constants by
+// EditPatchSetNum and ParentPatchSetNum in common.ts.
+export const SPECIAL_PATCH_SET_NUM = {
+  EDIT: 'edit',
+  PARENT: 'PARENT',
+};
+
+export interface PatchSet {
+  num: PatchSetNum;
+  desc: string | undefined;
+  sha: string;
+  wip?: boolean;
+}
+
+interface PatchRange {
+  patchNum?: PatchSetNum;
+  basePatchNum?: PatchSetNum;
+}
+
+/**
+ * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
+ * this function checks for patchNum equality.
+ *
+ */
+export function patchNumEquals(a?: PatchSetNum, b?: PatchSetNum) {
+  if (a === undefined) {
+    return a === b;
+  }
+  // TODO(TS): replace with a===b when the whole code is converted to ts
+  return `${a}` === `${b}`;
+}
+
+/**
+ * Whether the given patch is a numbered parent of a merge (i.e. a negative
+ * number).
+ */
+export function isMergeParent(n: PatchSetNum) {
+  return `${n}`[0] === '-';
+}
+
+export function isPatchSetNum(patchset: string) {
+  if (!isNaN(Number(patchset))) return true;
+  return patchset === EditPatchSetNum || patchset === ParentPatchSetNum;
+}
+
+export function convertToPatchSetNum(
+  patchset: string | undefined
+): PatchSetNum | undefined {
+  if (patchset === undefined) return patchset;
+  if (!isPatchSetNum(patchset)) {
+    console.error('string is not of type PatchSetNum');
+  }
+  const value = Number(patchset);
+  if (!isNaN(value)) return value as PatchSetNum;
+  return patchset as PatchSetNum;
+}
+
+export function isNumber(
+  psn: PatchSetNum
+): psn is BrandType<number, '_patchSet'> {
+  return typeof psn === 'number';
+}
+
+/**
+ * Given an object of revisions, get a particular revision based on patch
+ * num.
+ *
+ * @return The correspondent revision obj from {revisions}
+ */
+export function getRevisionByPatchNum(
+  revisions: RevisionInfo[],
+  patchNum: PatchSetNum
+) {
+  for (const rev of revisions) {
+    if (patchNumEquals(rev._number, patchNum)) {
+      return rev;
+    }
+  }
+  console.warn('no revision found');
+  return;
+}
+
+/**
+ * Find change edit base revision if change edit exists.
+ *
+ * @return change edit parent revision or null if change edit
+ *     doesn't exist.
+ *
+ */
+export function findEditParentRevision(
+  revisions: Array<RevisionInfo | EditRevisionInfo>
+) {
+  const editInfo = revisions.find(info => info._number === EditPatchSetNum);
+
+  if (!editInfo) {
+    return null;
+  }
+
+  return revisions.find(info => info._number === editInfo.basePatchNum) || null;
+}
+
+/**
+ * Find change edit base patch set number if change edit exists.
+ *
+ * @return Change edit patch set number or -1.
+ *
+ */
+export function findEditParentPatchNum(
+  revisions: Array<RevisionInfo | EditRevisionInfo>
+) {
+  const revisionInfo = findEditParentRevision(revisions);
+  // finding parent of 'edit' patchset, hence revisionInfo._number cannot be
+  // 'edit' and must be a number
+  // TODO(TS): find a way to avoid 'as'
+  return revisionInfo ? (revisionInfo._number as 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.
+ *
+ */
+export function sortRevisions<T extends RevisionInfo | EditRevisionInfo>(
+  revisions: T[]
+): T[] {
+  const editParent: number = 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.
+  // TODO(TS): find a way to avoid 'as'
+  const num = (r: T) =>
+    r._number === EditPatchSetNum
+      ? 2 * editParent
+      : 2 * ((r._number as 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 The number identifying the patch set
+ *   * desc Optional patch set description
+ *   * wip If true, this patch set was never subject to review.
+ *   * sha hash of the commit
+ *
+ * The wip property is determined by the change's current work_in_progress
+ * property and its log of change messages.
+ *
+ * @return Sorted list of patch set objects, as described
+ *     above
+ */
+export function computeAllPatchSets(
+  change: ChangeInfo | ParsedChangeInfo
+): PatchSet[] {
+  if (!change) {
+    return [];
+  }
+
+  let patchNums: PatchSet[] = [];
+  if (change.revisions && Object.keys(change.revisions).length) {
+    const changeRevisions = change.revisions;
+    const revisions = Object.keys(change.revisions).map(sha => {
+      return {sha, ...changeRevisions[sha]};
+    });
+    patchNums = 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 _computeWipForPatchSets(change, patchNums);
+}
+
+/**
+ * Populate the wip properties of the given list of patch sets.
+ *
+ * @param change The change details
+ * @param patchNums Sorted list of patch set objects, as
+ *     generated by computeAllPatchSets
+ * @return The given list of patch set objects, with the
+ *     wip property set on each of them
+ */
+function _computeWipForPatchSets(
+  change: ChangeInfo | ParsedChangeInfo,
+  patchNums: PatchSet[]
+) {
+  if (!change.messages || !change.messages.length) {
+    return patchNums;
+  }
+  // TODO(TS): replace with Map<PatchNum, boolean>
+  const psWip: Map<string, boolean> = new Map();
+  let wip = !!change.work_in_progress;
+  for (let i = 0; i < change.messages.length; i++) {
+    const msg = change.messages[i];
+    if (msg.tag && WIP_TAGS.includes(msg.tag)) {
+      wip = true;
+    } else if (msg.tag && READY_TAGS.includes(msg.tag)) {
+      wip = false;
+    }
+    if (
+      msg._revision_number &&
+      psWip.get(`${msg._revision_number}`) !== false
+    ) {
+      psWip.set(`${msg._revision_number}`, wip);
+    }
+  }
+
+  for (let i = 0; i < patchNums.length; i++) {
+    patchNums[i].wip = psWip.get(`${patchNums[i].num}`);
+  }
+  return patchNums;
+}
+
+export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
+
+export function computeLatestPatchNum(
+  allPatchSets?: PatchSet[]
+): PatchSetNum | undefined {
+  if (!allPatchSets || !allPatchSets.length) {
+    return undefined;
+  }
+  if (allPatchSets[0].num === EditPatchSetNum) {
+    return allPatchSets[1].num;
+  }
+  return allPatchSets[0].num;
+}
+
+export function hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+  if (!allPatchSets || allPatchSets.length < 2) {
+    return false;
+  }
+  return allPatchSets[0].num === EditPatchSetNum;
+}
+
+export function hasEditPatchsetLoaded(patchRange: PatchRange) {
+  return (
+    patchRange.patchNum === EditPatchSetNum ||
+    patchRange.basePatchNum === EditPatchSetNum
+  );
+}
+
+/**
+ * Check whether there is no newer patch than the latest patch that was
+ * available when this change was loaded.
+ *
+ * @return A promise that yields true if the latest patch
+ *     has been loaded, and false if a newer patch has been uploaded in the
+ *     meantime. The promise is rejected on network error.
+ */
+export function fetchChangeUpdates(
+  change: ChangeInfo | ParsedChangeInfo,
+  restAPI: RestApiService
+) {
+  const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+  return restAPI.getChangeDetail(change._number).then(detail => {
+    if (!detail) {
+      const error = new Error('Change detail not found.');
+      return Promise.reject(error);
+    }
+    const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+    if (!actualLatest || !knownLatest) {
+      const error = new Error('Unable to check for latest patchset.');
+      return Promise.reject(error);
+    }
+    return {
+      isLatest: actualLatest <= knownLatest,
+      newStatus: change.status !== detail.status ? detail.status : null,
+      newMessages:
+        (change.messages || []).length < (detail.messages || []).length,
+    };
+  });
+}
+
+/**
+ * @param revisions A sorted array of revisions.
+ *
+ * @return the index of the revision with the given patchNum.
+ */
+export function findSortedIndex(
+  patchNum: PatchSetNum,
+  revisions: RevisionInfo[]
+) {
+  revisions = revisions || [];
+  const findNum = (rev: RevisionInfo) => `${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`.
+ *
+ */
+
+export function getParentIndex(rangeBase: PatchSetNum) {
+  return -Number(`${rangeBase}`);
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.js b/polygerrit-ui/app/utils/patch-set-util_test.js
new file mode 100644
index 0000000..29cc370
--- /dev/null
+++ b/polygerrit-ui/app/utils/patch-set-util_test.js
@@ -0,0 +1,339 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+  _testOnly_computeWipForPatchSets, computeAllPatchSets,
+  fetchChangeUpdates, findEditParentPatchNum, findEditParentRevision,
+  getParentIndex, getRevisionByPatchNum,
+  isMergeParent,
+  patchNumEquals, sortRevisions,
+} from './patch-set-util.js';
+
+suite('gr-patch-set-util tests', () => {
+  test('getRevisionByPatchNum', () => {
+    const revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.deepEqual(getRevisionByPatchNum(revisions, '1'), revisions[1]);
+    assert.deepEqual(getRevisionByPatchNum(revisions, 2), revisions[2]);
+    assert.equal(getRevisionByPatchNum(revisions, '3'), undefined);
+  });
+
+  test('fetchChangeUpdates on latest', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(knownChange);
+      },
+    };
+    fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.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);
+      },
+    };
+    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);
+      },
+    };
+    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);
+      },
+    };
+    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 = _testOnly_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', () => {
+    assert.isFalse(patchNumEquals('edit', 'PARENT'));
+    assert.isFalse(patchNumEquals('edit', NaN));
+    assert.isFalse(patchNumEquals(1, '2'));
+
+    assert.isTrue(patchNumEquals(1, '1'));
+    assert.isTrue(patchNumEquals(1, 1));
+    assert.isTrue(patchNumEquals('edit', 'edit'));
+    assert.isTrue(patchNumEquals('PARENT', 'PARENT'));
+  });
+
+  test('isMergeParent', () => {
+    assert.isFalse(isMergeParent(1));
+    assert.isFalse(isMergeParent(4321));
+    assert.isFalse(isMergeParent('52'));
+    assert.isFalse(isMergeParent('edit'));
+    assert.isFalse(isMergeParent('PARENT'));
+    assert.isFalse(isMergeParent(0));
+
+    assert.isTrue(isMergeParent(-23));
+    assert.isTrue(isMergeParent(-1));
+    assert.isTrue(isMergeParent('-42'));
+  });
+
+  test('findEditParentRevision', () => {
+    let revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions = [...revisions, {_number: 3}];
+    assert.deepEqual(findEditParentRevision(revisions), {_number: 3});
+  });
+
+  test('findEditParentPatchNum', () => {
+    let revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.equal(findEditParentPatchNum(revisions), -1);
+
+    revisions =
+        [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
+    assert.deepEqual(findEditParentPatchNum(revisions), 3);
+  });
+
+  test('sortRevisions', () => {
+    const revisions = [
+      {_number: 0},
+      {_number: 2},
+      {_number: 1},
+    ];
+    const sorted = [
+      {_number: 2},
+      {_number: 1},
+      {_number: 0},
+    ];
+
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    // Edit patchset should follow directly after its basePatchNum.
+    revisions.push({_number: 'edit', basePatchNum: 2});
+    sorted.unshift({_number: 'edit', basePatchNum: 2});
+    assert.deepEqual(sortRevisions(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(sortRevisions(revisions), sorted);
+  });
+
+  test('getParentIndex', () => {
+    assert.equal(getParentIndex('-13'), 13);
+    assert.equal(getParentIndex(-4), 4);
+  });
+
+  test('computeAllPatchSets', () => {
+    const expected = [
+      {num: 4, desc: 'test', sha: 'rev4'},
+      {num: 3, desc: 'test', sha: 'rev3'},
+      {num: 2, desc: 'test', sha: 'rev2'},
+      {num: 1, desc: 'test', sha: 'rev1'},
+    ];
+    const patchNums = computeAllPatchSets({
+      revisions: {
+        rev3: {_number: 3, description: 'test', date: 3},
+        rev1: {_number: 1, description: 'test', date: 1},
+        rev4: {_number: 4, description: 'test', date: 4},
+        rev2: {_number: 2, description: 'test', date: 2},
+      },
+    });
+    assert.equal(patchNums.length, expected.length);
+    for (let i = 0; i < expected.length; i++) {
+      assert.deepEqual(patchNums[i], expected[i]);
+    }
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
new file mode 100644
index 0000000..dda6031
--- /dev/null
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -0,0 +1,125 @@
+/**
+ * @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 {SpecialFilePath, FileInfoStatus} from '../constants/constants';
+import {FileInfo} from '../types/common';
+import {hasOwnProperty} from './common-util';
+
+export function specialFilePathCompare(a: string, b: string) {
+  // The commit message always goes first.
+  if (a === SpecialFilePath.COMMIT_MESSAGE) {
+    return -1;
+  }
+  if (b === SpecialFilePath.COMMIT_MESSAGE) {
+    return 1;
+  }
+
+  // The merge list always comes next.
+  if (a === SpecialFilePath.MERGE_LIST) {
+    return -1;
+  }
+  if (b === SpecialFilePath.MERGE_LIST) {
+    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);
+}
+
+export function shouldHideFile(file: string) {
+  return file === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
+export function addUnmodifiedFiles(
+  files: {[filename: string]: FileInfo},
+  commentedPaths: {[fileName: string]: boolean}
+) {
+  if (!commentedPaths) return;
+  Object.keys(commentedPaths).forEach(commentedPath => {
+    if (hasOwnProperty(files, commentedPath) || shouldHideFile(commentedPath)) {
+      return;
+    }
+    // TODO(TS): either change FileInfo to mark delta and size optional
+    // or fill in 0 here
+    files[commentedPath] = {
+      status: FileInfoStatus.UNMODIFIED,
+    } as FileInfo;
+  });
+}
+
+export function computeDisplayPath(path: string) {
+  if (path === SpecialFilePath.COMMIT_MESSAGE) {
+    return 'Commit message';
+  } else if (path === SpecialFilePath.MERGE_LIST) {
+    return 'Merge list';
+  }
+  return path;
+}
+
+export function isMagicPath(path?: string) {
+  return (
+    !!path &&
+    (path === SpecialFilePath.COMMIT_MESSAGE ||
+      path === SpecialFilePath.MERGE_LIST)
+  );
+}
+
+export function computeTruncatedPath(path: string) {
+  return truncatePath(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');
+ *
+ */
+export function truncatePath(path: string, 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('/')}`;
+}
diff --git a/polygerrit-ui/app/utils/path-list-util_test.js b/polygerrit-ui/app/utils/path-list-util_test.js
new file mode 100644
index 0000000..4d06344
--- /dev/null
+++ b/polygerrit-ui/app/utils/path-list-util_test.js
@@ -0,0 +1,161 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {SpecialFilePath} from '../constants/constants.js';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  isMagicPath,
+  specialFilePathCompare, truncatePath,
+} from './path-list-util.js';
+
+suite('path-list-utl tests', () => {
+  test('special sort', () => {
+    const testFiles = [
+      '/a.h',
+      '/MERGE_LIST',
+      '/a.cpp',
+      '/COMMIT_MSG',
+      '/asdasd',
+      '/mrPeanutbutter.py',
+    ];
+    assert.deepEqual(
+        testFiles.sort(specialFilePathCompare),
+        [
+          '/COMMIT_MSG',
+          '/MERGE_LIST',
+          '/a.h',
+          '/a.cpp',
+          '/asdasd',
+          '/mrPeanutbutter.py',
+        ]);
+  });
+
+  test('special file path sorting', () => {
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
+            specialFilePathCompare),
+        ['/COMMIT_MSG', '.a', '.b', 'file']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
+            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(
+            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(
+            specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
+            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(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(specialFilePathCompare),
+        [
+          'task.go',
+          'task_test.go',
+        ]);
+  });
+
+  test('file display name', () => {
+    assert.equal(computeDisplayPath('/foo/bar/baz'), '/foo/bar/baz');
+    assert.equal(computeDisplayPath('/foobarbaz'), '/foobarbaz');
+    assert.equal(computeDisplayPath('/COMMIT_MSG'), 'Commit message');
+    assert.equal(computeDisplayPath('/MERGE_LIST'), 'Merge list');
+  });
+
+  test('isMagicPath', () => {
+    assert.isFalse(isMagicPath(undefined));
+    assert.isFalse(isMagicPath('/foo.cc'));
+    assert.isTrue(isMagicPath('/COMMIT_MSG'));
+    assert.isTrue(isMagicPath('/MERGE_LIST'));
+  });
+
+  test('patchset level comments are hidden', () => {
+    const commentedPaths = {
+      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: true,
+      'file1.txt': true,
+    };
+
+    const files = {'file2.txt': {status: 'M'}};
+    addUnmodifiedFiles(files, commentedPaths);
+    assert.equal(files['file1.txt'].status, 'U');
+    assert.equal(files['file2.txt'].status, 'M');
+    assert.isFalse(files.hasOwnProperty(
+        SpecialFilePath.PATCHSET_LEVEL_COMMENTS));
+  });
+
+  test('truncatePath with long path should add ellipsis', () => {
+    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', () => {
+    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 path = 'file.js';
+    const expectedPath = 'file.js';
+    const shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/safari-selection-util.ts b/polygerrit-ui/app/utils/safari-selection-util.ts
new file mode 100644
index 0000000..9c56c56
--- /dev/null
+++ b/polygerrit-ui/app/utils/safari-selection-util.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {isSafari, findActiveElement} from './dom-util';
+
+const SUPPORTS_SHADOW_SELECTION =
+  typeof window.ShadowRoot.prototype.getSelection === 'function';
+const SUPPORTS_BEFORE_INPUT =
+  typeof (window.InputEvent.prototype as any).getTargetRanges === 'function';
+
+const TARGET_ID = 'diffTable';
+
+let processing = false;
+let contentEditableRange: Range | null = null;
+
+interface InputEventExtended extends InputEvent {
+  getTargetRanges(): StaticRange[];
+}
+
+if (isSafari() && !SUPPORTS_SHADOW_SELECTION && SUPPORTS_BEFORE_INPUT) {
+  /**
+   * This library aims at extracting the selection range in a content editable
+   * area. It is a hacky solution to work around the fact that Safari does not
+   * allow to get the selection from shadow dom anymore.
+   *
+   * The main idea behind this approach is the following:
+   * - Listen to 'selectionChange' events of 'contentEditable' areas.
+   * - Trigger a 'beforeInput' event by running and immediately terminating
+   *   an arbitrary `execCommand`.
+   * - use the getTargetRanges() method to get a list of static ranges
+   *
+   * This typescript snippet is the porting of that idea (as explained by its
+   * original author [1]).
+   *
+   * [1] https://github.com/GoogleChromeLabs/shadow-selection-polyfill/issues/11
+   */
+
+  window.addEventListener(
+    'selectionchange',
+    () => {
+      if (!processing) {
+        processing = true;
+        const active = findActiveElement(document, true);
+        if (active && active.id === TARGET_ID) {
+          // Safari does not allow to select inside a shadowRoot, so we use an
+          // `execCommand` to trigger a `beforeInput` event in order to
+          // get at the target range from the event.
+          document.execCommand('indent');
+        }
+        processing = false;
+      }
+    },
+    true
+  );
+
+  window.addEventListener(
+    'beforeinput',
+    event => {
+      if (processing) {
+        // selecting
+        const inputEvent = event as InputEventExtended;
+        if (typeof inputEvent.getTargetRanges !== 'function') return;
+        const range = inputEvent.getTargetRanges()[0];
+
+        const newRange = new Range();
+
+        newRange.setStart(range.startContainer, range.startOffset);
+        newRange.setEnd(range.endContainer, range.endOffset);
+
+        contentEditableRange = newRange;
+
+        event.preventDefault();
+        event.stopImmediatePropagation();
+      } else {
+        // typing
+        const active = findActiveElement(document, true);
+        if (active && active.id === TARGET_ID) {
+          // Prevent diff content from actually being edited: Making the diff
+          // table content editable is just a mechanism to allow processing
+          // 'beforeInput' events, but the content itself should not be editable
+          event.preventDefault();
+          event.stopImmediatePropagation();
+        }
+      }
+    },
+    true
+  );
+
+  window.addEventListener(
+    'selectstart',
+    _ => {
+      contentEditableRange = null;
+    },
+    true
+  );
+}
+
+export function getContentEditableRange() {
+  return contentEditableRange;
+}
diff --git a/polygerrit-ui/app/utils/safe-types-util.ts b/polygerrit-ui/app/utils/safe-types-util.ts
new file mode 100644
index 0000000..18641de
--- /dev/null
+++ b/polygerrit-ui/app/utils/safe-types-util.ts
@@ -0,0 +1,68 @@
+/**
+ * @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;
+
+/**
+ * Wraps a string to be used as a URL. An error is thrown if the string cannot
+ * be considered safe.
+ */
+class SafeUrl {
+  private readonly _url: string;
+
+  constructor(url: string) {
+    if (!SAFE_URL_PATTERN.test(url)) {
+      throw new Error(`URL not marked as safe: ${url}`);
+    }
+    this._url = url;
+  }
+
+  toString() {
+    return this._url;
+  }
+}
+
+export const _testOnly_SafeUrl = SafeUrl;
+
+/**
+ * Get the string representation of the safe URL.
+ */
+export function safeTypesBridge(value: unknown, type: string): unknown {
+  // 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 SafeUrl) {
+      safeValue = value;
+    } else if (typeof value === 'string') {
+      safeValue = new SafeUrl(value);
+    }
+    if (safeValue) {
+      return safeValue.toString();
+    }
+  }
+
+  // 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}`);
+}
diff --git a/polygerrit-ui/app/utils/safe-types-util_test.js b/polygerrit-ui/app/utils/safe-types-util_test.js
new file mode 100644
index 0000000..e3968d0
--- /dev/null
+++ b/polygerrit-ui/app/utils/safe-types-util_test.js
@@ -0,0 +1,87 @@
+/**
+ * @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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {safeTypesBridge, _testOnly_SafeUrl} from './safe-types-util.js';
+
+suite('safe-types-util tests', () => {
+  test('SafeUrl accepts valid urls', () => {
+    function accepts(url) {
+      const safeUrl = new _testOnly_SafeUrl(url);
+      assert.isOk(safeUrl);
+      assert.equal(url, safeUrl.toString());
+    }
+    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 _testOnly_SafeUrl(url); });
+    }
+    rejects('javascript://alert("evil");');
+    rejects('ftp:example.com');
+    rejects('data:text/html,scary business');
+  });
+
+  suite('safeTypesBridge', () => {
+    function acceptsString(value, type) {
+      assert.equal(safeTypesBridge(value, type),
+          value);
+    }
+
+    function rejects(value, type) {
+      assert.throws(() => { 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 _testOnly_SafeUrl(url);
+      assert.equal(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');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
new file mode 100644
index 0000000..0c6fabc
--- /dev/null
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -0,0 +1,80 @@
+import {ServerInfo} from '../types/common';
+import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
+
+/**
+ * @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 PROBE_PATH = '/Documentation/index.html';
+const DOCS_BASE_PATH = '/Documentation';
+
+export function getBaseUrl(): string {
+  return window.CANONICAL_PATH || '';
+}
+
+let getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
+
+/**
+ * Get the docs base URL from either the server config or by probing.
+ *
+ * @return A promise that resolves with the docs base URL.
+ */
+export function getDocsBaseUrl(
+  config: ServerInfo | undefined,
+  restApi: RestApiService
+): Promise<string | null> {
+  if (!getDocsBaseUrlCachedPromise) {
+    getDocsBaseUrlCachedPromise = new Promise(resolve => {
+      if (config?.gerrit?.doc_url) {
+        resolve(config.gerrit.doc_url);
+      } else {
+        restApi.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
+          resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null);
+        });
+      }
+    });
+  }
+  return getDocsBaseUrlCachedPromise;
+}
+
+export function _testOnly_clearDocsBaseUrlCache() {
+  getDocsBaseUrlCachedPromise = undefined;
+}
+
+/**
+ * Pretty-encodes a URL. Double-encodes the string, and then replaces
+ *   benevolent characters for legibility.
+ */
+export function encodeURL(url: string, replaceSlashes?: boolean): string {
+  // @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.
+ */
+export function singleDecodeURL(url: string): string {
+  const withoutPlus = url.replace(/\+/g, '%20');
+  return decodeURIComponent(withoutPlus);
+}
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.js
new file mode 100644
index 0000000..0658be3
--- /dev/null
+++ b/polygerrit-ui/app/utils/url-util_test.js
@@ -0,0 +1,127 @@
+/**
+ * @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 '../test/common-test-setup-karma.js';
+import {
+  getBaseUrl,
+  getDocsBaseUrl,
+  _testOnly_clearDocsBaseUrlCache,
+  encodeURL, singleDecodeURL,
+} from './url-util.js';
+
+suite('url-util tests', () => {
+  suite('getBaseUrl tests', () => {
+    let originialCanonicalPath;
+
+    suiteSetup(() => {
+      originialCanonicalPath = window.CANONICAL_PATH;
+      window.CANONICAL_PATH = '/r';
+    });
+
+    suiteTeardown(() => {
+      window.CANONICAL_PATH = originialCanonicalPath;
+    });
+
+    test('getBaseUrl', () => {
+      assert.deepEqual(getBaseUrl(), '/r');
+    });
+  });
+
+  suite('getDocsBaseUrl tests', () => {
+    setup(() => {
+      _testOnly_clearDocsBaseUrlCache();
+    });
+
+    test('null config', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
+      assert.isTrue(
+          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      assert.equal(docsBaseUrl, '/Documentation');
+    });
+
+    test('no doc config', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const config = {gerrit: {}};
+      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
+      assert.isTrue(
+          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      assert.equal(docsBaseUrl, '/Documentation');
+    });
+
+    test('has doc config', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const config = {gerrit: {doc_url: 'foobar'}};
+      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
+      assert.isFalse(mockRestApi.probePath.called);
+      assert.equal(docsBaseUrl, 'foobar');
+    });
+
+    test('no probe', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(false)),
+      };
+      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
+      assert.isTrue(
+          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      assert.isNotOk(docsBaseUrl);
+    });
+  });
+
+  suite('url encoding and decoding tests', () => {
+    suite('encodeURL', () => {
+      test('double encodes', () => {
+        assert.equal(encodeURL('abc?123'), 'abc%253F123');
+        assert.equal(encodeURL('def/ghi'), 'def%252Fghi');
+        assert.equal(encodeURL('jkl'), 'jkl');
+        assert.equal(encodeURL(''), '');
+      });
+
+      test('does not convert colons', () => {
+        assert.equal(encodeURL('mno:pqr'), 'mno:pqr');
+      });
+
+      test('converts spaces to +', () => {
+        assert.equal(encodeURL('words with spaces'), 'words+with+spaces');
+      });
+
+      test('does not convert slashes when configured', () => {
+        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
+      });
+
+      test('does not convert slashes when configured', () => {
+        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
+      });
+    });
+
+    suite('singleDecodeUrl', () => {
+      test('single decodes', () => {
+        assert.equal(singleDecodeURL('abc%3Fdef'), 'abc?def');
+      });
+
+      test('converts + to space', () => {
+        assert.equal(singleDecodeURL('ghi+jkl'), 'ghi jkl');
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/wct.conf.js b/polygerrit-ui/app/wct.conf.js
deleted file mode 100644
index 1a9300e..0000000
--- a/polygerrit-ui/app/wct.conf.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/*
-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
deleted file mode 100755
index 42b98ab..0000000
--- a/polygerrit-ui/app/wct_test.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/sh
-
-set -ex
-root_dir=$(pwd)
-t=$TEST_TMPDIR
-export JSON_CONFIG=$2
-
-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
-
-# 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
-
-cp -R -L ./polygerrit-ui/app/* $t/
-
-export PATH="$(dirname $NPM):$PATH"
-
-cd $t
-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
-
-# 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
index 927c19b..f6664fd 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,6 +2,13 @@
 # yarn lockfile v1
 
 
+"@polymer/decorators@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
+  integrity sha512-qh+VID9nDV9q3ABvIfWgm7/+udl7v2HKsMLPXFm8tj1fI7qr7yWJMFwS3xWBkMmuNPtmkS8MDP0vqLAQIEOWzg==
+  dependencies:
+    "@polymer/polymer" "^3.0.5"
+
 "@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"
@@ -12,10 +19,10 @@
   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==
+"@polymer/iron-a11y-announcer@^3.0.0-pre.26", "@polymer/iron-a11y-announcer@^3.0.1":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.1.0.tgz#3d3712a165070ed3cdfc39e54f95515c913c9613"
+  integrity sha512-lc5i4NKB8kSQHH0Hwu8WS3ym93m+J69OHJWSSBxwd17FI+h2wmgxDzeG9LI4ojMMck17/uc2pLe7g/UHt5/K/A==
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
@@ -26,10 +33,10 @@
   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==
+"@polymer/iron-autogrow-textarea@^3.0.0-pre.26", "@polymer/iron-autogrow-textarea@^3.0.3":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-autogrow-textarea/-/iron-autogrow-textarea-3.0.3.tgz#b75dbebc23ce47d428a26156709d4a8a4c05823e"
+  integrity sha512-5r0VkWrIlm0JIp5E5wlnvkw7slK72lFRZXncmrsLZF+6n1dg2rI8jt7xpFzSmUWrqpcyXwyKaGaDvUjl3j4JLA==
   dependencies:
     "@polymer/iron-behaviors" "^3.0.0-pre.26"
     "@polymer/iron-flex-layout" "^3.0.0-pre.26"
@@ -63,7 +70,7 @@
     "@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":
+"@polymer/iron-fit-behavior@^3.0.0-pre.26", "@polymer/iron-fit-behavior@^3.0.2":
   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==
@@ -127,7 +134,7 @@
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.2":
+"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.3":
   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==
@@ -227,10 +234,10 @@
     "@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==
+"@polymer/paper-input@^3.2.1":
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-input/-/paper-input-3.2.1.tgz#0fd0d30de3b43ba7d2c8d5d76f870d257b667ebf"
+  integrity sha512-6ghgwQKM6mS0hAQxQqj+tkeEY1VUBqAsrasAm8V5RpNcfSWQC/hhRFxU0beGuKTAhndzezDzWYP6Zz4b8fExGg==
   dependencies:
     "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
     "@polymer/iron-autogrow-textarea" "^3.0.0-pre.26"
@@ -303,7 +310,14 @@
     "@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":
+"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
+  integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+  dependencies:
+    "@webcomponents/shadycss" "^1.9.1"
+
+"@polymer/polymer@^3.0.2":
   version "3.3.1"
   resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.3.1.tgz#9ad48992d2a96775f80b0673f3a615d6df8a3dfc"
   integrity sha512-8KaB48tzyMjdsHdxo5KvCAaqmTe7rYDzQAoj/pyEfq9Fp4YfUaS+/xqwYj0GbiDAUNzwkmEQ7dw9cgnRNdKO8A==
@@ -328,21 +342,11 @@
 "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"
@@ -368,12 +372,3 @@
     "@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/grep-patch-karma.js b/polygerrit-ui/grep-patch-karma.js
new file mode 100644
index 0000000..adf5171
--- /dev/null
+++ b/polygerrit-ui/grep-patch-karma.js
@@ -0,0 +1,47 @@
+/**
+ * @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 IntelliJ (and probably other IDEs) passes test names as a regexp in
+// the format:
+// --grep=/some regexp.../
+// But mochajs doesn't expect the '/' characters before and after the regexp.
+// The code below patches input args and removes '/' if they exists.
+function installPatch(karma) {
+  const originalKarmaStart = karma.start;
+
+  karma.start = function(config, ...args) {
+    const regexpGrepPrefix = '--grep=/';
+    const regexpGrepSuffix = '/';
+    if (config && config.args) {
+      for (let i = 0; i < config.args.length; i++) {
+        const arg = config.args[i];
+        if (arg.startsWith(regexpGrepPrefix) && arg.endsWith(regexpGrepSuffix)) {
+          const regexpText = arg.slice(regexpGrepPrefix.length, -regexpGrepPrefix.length);
+          config.args[i] = '--grep=' + regexpText;
+        }
+      }
+    }
+    originalKarmaStart.apply(this, [config, ...args]);
+  }
+
+}
+
+const karma = window.__karma__;
+if (karma && karma.start && !karma.__grep_patch_installed__) {
+  karma.__grep_patch_installed__ = true;
+  installPatch(karma);
+}
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
new file mode 100644
index 0000000..3f7221a
--- /dev/null
+++ b/polygerrit-ui/karma.conf.js
@@ -0,0 +1,221 @@
+/**
+ * @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 runUnderBazel = !!process.env["RUNFILES_DIR"];
+const path = require('path');
+
+function getModulesDir() {
+  if(runUnderBazel) {
+    // Run under bazel
+    return [
+      `external/ui_npm/node_modules`,
+      `external/ui_dev_npm/node_modules`
+    ];
+  }
+
+  // Run from intellij or npm run test:kdebug
+  return [
+    path.join(__dirname, 'app/node_modules'),
+    path.join(__dirname, 'node_modules'),
+  ];
+}
+
+function getUiDevNpmFilePath(importPath) {
+  if(runUnderBazel) {
+    return `external/ui_dev_npm/node_modules/${importPath}`;
+  }
+  else {
+    return `polygerrit-ui/node_modules/${importPath}`
+  }
+}
+
+function runInIde() {
+  // A simple detection of IDE.
+  // Default browserNoActivityTimeout is 30 seconds. An IDE usually
+  // runs karma in background and send commands when a user wants to
+  // execute test. If interval between user executed tests is bigger than
+  // browserNoActivityTimeout, the IDE reports error and doesn't restart
+  // server.
+  // We want to increase browserNoActivityTimeout when tests run in IDE.
+  // Wd don't want to increase it in other cases, oterhise hanging tests
+  // can slow down CI.
+  return !runUnderBazel &&
+      process.argv.some(arg => arg.toLowerCase().contains('intellij'));
+}
+
+module.exports = function(config) {
+  const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
+  const rootDir = runUnderBazel ?
+      'polygerrit-ui/app/_pg_with_tests_out/' : localDirName + '/';
+  const testFilesLocationPattern =
+      `${rootDir}**/!(template_test_srcs)/`;
+  // Use --test-files to specify pattern for a test files.
+  // It can be just a file name, without a path:
+  // --test-files async-foreach-behavior_test.js
+  // If you specify --test-files without pattern, it gets true value
+  // In this case we ill run all tests (usefull for package.json "debugtest"
+  // script)
+  const testFilesPattern = (typeof config.testFiles == 'string') ?
+      testFilesLocationPattern + config.testFiles :
+      testFilesLocationPattern + '*_test.js';
+  // Special patch for grep parameters (see details in the grep-patch-karam.js)
+  const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
+  config.set({
+    browserNoActivityTimeout: runInIde ? 60 * 60 * 1000 : 30 * 1000,
+    // base path that will be used to resolve all patterns (eg. files, exclude)
+    basePath: '../',
+    plugins: [
+      // Do not use karma-* to load all installed plugin
+      // This can lead to unexpected behavior under bazel
+      // if you forget to add a plugin in a bazel rule.
+      require.resolve('@open-wc/karma-esm'),
+      'karma-mocha',
+      'karma-chrome-launcher',
+      'karma-mocha-reporter',
+    ],
+    // frameworks to use
+    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
+    frameworks: ['mocha', 'esm'],
+
+    // list of files / patterns to load in the browser
+    files: [
+      ...additionalFiles,
+      getUiDevNpmFilePath('source-map-support/browser-source-map-support.js'),
+      getUiDevNpmFilePath('accessibility-developer-tools/dist/js/axs_testing.js'),
+      getUiDevNpmFilePath('sinon/pkg/sinon.js'),
+      { pattern: testFilesPattern, type: 'module' },
+    ],
+    esm: {
+      nodeResolve: {
+        // By default, it tries to use page.mjs file instead of page.js
+        // when importing 'page/page', so we shouldn't use .mjs extension
+        // in node resolve.
+        // The .ts extension is required to display source code in browser
+        // (otherwise esm plugin crashes)
+        extensions: ['.js', '.ts'],
+      },
+      moduleDirs: getModulesDir(),
+      // Bazel and yarn uses symlinks for files.
+      // preserveSymlinks is necessary for correct modules paths resolving
+      preserveSymlinks: true,
+      // By default, esm-dev-server uses 'auto' compatibility mode.
+      // In the 'auto' mode it incorrectly applies polyfills and
+      // breaks tests in some browser versions
+      // (for example, Chrome 69 on gerrit-ci).
+      compatibility: 'none',
+      plugins: [
+        {
+          resolveImport(importSpecifier) {
+            // esm-dev-server interprets .ts files as .js files and
+            // tries to replace all module imports with relative/absolute
+            // paths. In most cases this works correctly. However if
+            // a ts file imports type from .d.ts and there is no
+            // associated .js file then the esm-dev-server responds with
+            // 500 error.
+            // For example the following import .ts file causes problem
+            // import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+            // To avoid problems, we don't resolve imports in .ts files
+            // and instead always return original path
+            if (importSpecifier.context.originalUrl.endsWith(".ts")) {
+              return importSpecifier.source;
+            }
+            return undefined;
+          }
+        },
+        {
+          transform(context) {
+            if (context.path.endsWith('/node_modules/page/page.js')) {
+              const orignalBody = context.body;
+              // Can't import page.js directly, because this is undefined.
+              // Replace it with window
+              // The same replace exists in server.go
+              // Rollup makes this replacement automatically
+              const transformedBody = orignalBody.replace(
+                  '}(this, (function () { \'use strict\';',
+                  '}(window, (function () { \'use strict\';'
+              );
+              if(orignalBody.length === transformedBody.length) {
+                console.error('The page.js was updated. Please update transform accordingly');
+                process.exit(1);
+              }
+              return {body: transformedBody};
+            }
+          },
+        }
+      ]
+    },
+    // test results reporter to use
+    // possible values: 'dots', 'progress'
+    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+    reporters: ['mocha'],
+
+
+    // web server port
+    port: 9876,
+
+
+    // enable / disable colors in the output (reporters and logs)
+    colors: true,
+
+
+    // level of logging
+    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    logLevel: config.LOG_INFO,
+
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch: false,
+
+
+    // start these browsers
+    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
+    browsers: ["CustomChromeHeadless"],
+    browserForDebugging: "CustomChromeHeadlessWithDebugPort",
+
+
+    // Continuous Integration mode
+    // if true, Karma captures browsers, runs the tests and exits
+    singleRun: true,
+
+    // Concurrency level
+    // how many browser should be started simultaneous
+    concurrency: Infinity,
+
+    client: {
+      mocha: {
+        ui: 'tdd',
+        timeout: 5000,
+      }
+    },
+
+    customLaunchers: {
+      // Based on https://developers.google.com/web/updates/2017/06/headless-karma-mocha-chai
+      "CustomChromeHeadless": {
+        base: 'ChromeHeadless',
+        flags: ['--disable-translate', '--disable-extensions'],
+      },
+      "ChromeDev": {
+        base: 'Chrome',
+        flags: ['--disable-extensions', ' --auto-open-devtools-for-tabs'],
+      },
+      "CustomChromeHeadlessWithDebugPort": {
+        base: 'CustomChromeHeadless',
+        flags: ['--remote-debugging-port=9222'],
+      }
+    }
+  });
+};
diff --git a/polygerrit-ui/karma_test.sh b/polygerrit-ui/karma_test.sh
new file mode 100755
index 0000000..5fab442
--- /dev/null
+++ b/polygerrit-ui/karma_test.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+set -euo pipefail
+./$1 start $2 --single-run
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 3d35e3e..c01ef56 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -2,13 +2,26 @@
   "name": "polygerrit-ui-dev-dependencies",
   "description": "Gerrit Code Review - Polygerrit dev dependencies",
   "browser": true,
-  "dependencies": {},
+  "dependencies": {
+    "@types/chai": "^4.2.14",
+    "@types/lodash": "^4.14.162",
+    "@types/mocha": "^8.0.3",
+    "@types/sinon": "^9.0.8"
+  },
   "devDependencies": {
+    "@open-wc/karma-esm": "^2.16.16",
     "@polymer/iron-test-helpers": "^3.0.1",
+    "@polymer/test-fixture": "^4.0.2",
+    "accessibility-developer-tools": "^2.12.0",
     "chai": "^4.2.0",
-    "mocha": "^6.2.2",
-    "wct-browser-legacy": "^1.0.2",
-    "web-component-tester": "^6.9.2"
+    "karma": "^4.4.1",
+    "karma-chrome-launcher": "^3.1.0",
+    "karma-mocha": "^2.0.1",
+    "karma-mocha-reporter": "^2.2.5",
+    "lodash": "^4.17.15",
+    "mocha": "7.2.0",
+    "sinon": "^9.0.2",
+    "source-map-support": "^0.5.19"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 120aff5..124b924 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -29,9 +29,12 @@
 	"net/http"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"regexp"
 	"strings"
+	"sync"
+	"time"
 
 	"golang.org/x/tools/godoc/vfs/httpfs"
 	"golang.org/x/tools/godoc/vfs/zipfs"
@@ -59,12 +62,30 @@
 		log.Fatal(err)
 	}
 
+	compiledSrcPath := filepath.Join(workspace, "./.ts-out/server-go")
+
+	tsInstance := newTypescriptInstance(
+		filepath.Join(workspace, "./node_modules/.bin/tsc"),
+		filepath.Join(workspace, "./polygerrit-ui/app/tsconfig.json"),
+		compiledSrcPath,
+	)
+
+	if err := tsInstance.StartWatch(); err != nil {
+		log.Fatal(err)
+	}
+
 	dirListingMux := http.NewServeMux()
 	dirListingMux.Handle("/styles/", http.StripPrefix("/styles/", http.FileServer(http.Dir("app/styles"))))
+	dirListingMux.Handle("/samples/", http.StripPrefix("/samples/", http.FileServer(http.Dir("app/samples"))))
 	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.HandleFunc("/",
+		func(w http.ResponseWriter, req *http.Request) {
+			// If typescript compiler hasn't finished yet, wait for it
+			tsInstance.WaitForCompilationComplete()
+			handleSrcRequest(compiledSrcPath, dirListingMux, w, req)
+		})
 
 	http.Handle("/fonts/",
 		addDevHeadersMiddleware(http.FileServer(httpfs.New(zipfs.New(fontsArchive, "fonts")))))
@@ -104,7 +125,7 @@
 
 }
 
-func handleSrcRequest(dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
+func handleSrcRequest(compiledSrcPath string, dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
 	parsedUrl, err := url.Parse(originalRequest.RequestURI)
 	if err != nil {
 		writer.WriteHeader(500)
@@ -122,18 +143,67 @@
 	}
 
 	isJsFile := strings.HasSuffix(normalizedContentPath, ".js") || strings.HasSuffix(normalizedContentPath, ".mjs")
-	data, err := getContent(normalizedContentPath)
+	isTsFile := strings.HasSuffix(normalizedContentPath, ".ts")
+
+	// Source map in a compiled js file point to a file inside /app/... directory
+	// Browser tries to load original file from the directory when debugger is
+	// activated. In this case we return original content without any processing
+	isOriginalFileRequest := strings.HasPrefix(normalizedContentPath, "/polygerrit-ui/app/") && (isTsFile || isJsFile)
+
+	data, err := getContent(compiledSrcPath, normalizedContentPath, isOriginalFileRequest)
 	if err != nil {
-		data, err = getContent(normalizedContentPath + ".js")
+		if !isOriginalFileRequest {
+			data, err = getContent(compiledSrcPath, normalizedContentPath+".js", false)
+		}
 		if err != nil {
 			writer.WriteHeader(404)
 			return
 		}
 		isJsFile = true
 	}
-	if isJsFile {
-		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
+	if isOriginalFileRequest {
+		// Explicitly set text/html Content-Type. If live code tries
+		// to import javascript from the /app/ folder accidentally, browser fails
+		// with the import error, so we can catch this problem easily.
+		writer.Header().Set("Content-Type", "text/html")
+	} else if isJsFile {
+		// import ... from '@polymer/decorators'
+		// must be transformed into
+		// import ... from '@polymer/decorators/lib/decorators.js'
+		// The correct way to do it is to use value of the "main" property
+		// from the @polymer/decorators/package.json. However, parsing package.json
+		// is overcomplicated right now, hard-code exact path here.
+		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'@polymer/decorators';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '@polymer/decorators/lib/decorators.js';"))
+
+		// The following code updates import statements.
+		// 1. if an in imported file has .js or .mjs extension, the code keeps
+		//	  the file extension unchanged. Otherwise, it adds .js extension
+		// 2. For module imports it adds '/node_modules/' prefix.
+		//   Examples:
+		//   '@polymer/polymer.js' -> '/node_modules/@polymer/polymer.js'
+		//   'page/page.mjs' -> '/node_modules/page.mjs'
+		//   '@polymer/iron-icon' -> '/node_modules/@polymer/iron-icon.js'
+		//   './element/file' -> './element/file.js'
+		moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*)'(.*?)(\.(m?)js)?';$`)
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '$2.${4}js';"))
+
+		moduleImportRegexp = regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
 		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
+
+		if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
+			// Can't import page.js directly, because this is undefined.
+			// Replace it with window
+			// The same replace exists in karma.conf.js
+			// Rollup makes this replacement automatically
+			pageJsRegexp := regexp.MustCompile(`(?m)^}\(this, \(function \(\) { 'use strict';$`)
+			newData := pageJsRegexp.ReplaceAll(data, []byte("}(window, (function () { 'use strict';"))
+			if len(newData) == len(data) {
+				log.Fatal("The page.js was updated. Please update regexp/replace accordingly")
+			}
+			data = newData
+		}
+
 		writer.Header().Set("Content-Type", "application/javascript")
 	} else if strings.HasSuffix(normalizedContentPath, ".css") {
 		writer.Header().Set("Content-Type", "text/css")
@@ -149,9 +219,17 @@
 	writer.Write(data)
 }
 
-func getContent(normalizedContentPath string) ([]byte, error) {
+func getContent(compiledSrcPath string, normalizedContentPath string, isOriginalFileRequest bool) ([]byte, error) {
 	// normalizedContentPath must always starts with '/'
 
+	if isOriginalFileRequest {
+		data, err := ioutil.ReadFile(normalizedContentPath[len("/polygerrit-ui/"):])
+		if err != nil {
+			return nil, errors.New("File not found")
+		}
+		return data, nil
+	}
+
 	// 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
@@ -172,7 +250,7 @@
 		normalizedContentPath = "/elements/gr-app.js"
 	}
 
-	pathsToTry := []string{"app" + normalizedContentPath}
+	pathsToTry := []string{compiledSrcPath + normalizedContentPath, "app" + normalizedContentPath}
 	bowerComponentsSuffix := "/bower_components/"
 	nodeModulesPrefix := "/node_modules/"
 	testComponentsPrefix := "/components/"
@@ -404,7 +482,7 @@
 
 // Any path prefixes that should resolve to index.html.
 var (
-	fePaths    = []string{"/q/", "/c/", "/p/", "/x/", "/dashboard/", "/admin/", "/settings/"}
+	fePaths    = []string{"/q/", "/c/", "/id/", "/p/", "/x/", "/dashboard/", "/admin/", "/settings/"}
 	issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
 )
 
@@ -431,3 +509,136 @@
 	defer gzw.Close()
 	http.DefaultServeMux.ServeHTTP(gzw, r)
 }
+
+// Typescript compiler support
+// The code below runs typescript compiler in watch mode and redirect
+// all output from the compiler to the standard logger with the prefix "TSC -"
+// Additionally, the code analyzes messages produced by the typescript compiler
+// and allows to wait until compilation is finished.
+var (
+	tsStartingCompilation   = "- Starting compilation in watch mode..."
+	tsFileChangeDetectedMsg = "- File change detected. Starting incremental compilation..."
+	// If there is only one error typescript outputs:
+	// Found 1 error
+	// In all other cases it outputs
+	// Found X errors
+	tsStartWatchingMsg        = regexp.MustCompile(`^.* - Found \d+ error(s)?\. Watching for file changes\.$`)
+	waitForNextChangeInterval = 1 * time.Second
+)
+
+// typescriptLogWriter implements Writer interface and receives output
+// (stdout and stderr) from the typescript compiler. It reads incoming
+// data line-by-line, analyzes each line and updates compilationDoneWaiter
+// according to the current compiler state. Additionally, the
+// typescriptLogWriter passes all incoming lines to the underlying logger.
+type typescriptLogWriter struct {
+	// unfinishedLine stores the portion of line which was partially received
+	// (i.e. all text received after the last EOL (\n) mark.
+	unfinishedLine string
+	// logger is used to pass-through all received strings
+	logger *log.Logger
+	// when WaitGroup counter is 0 the compilation is complete
+	compilationDoneWaiter *sync.WaitGroup
+}
+
+func newTypescriptLogWriter(compilationCompleteWaiter *sync.WaitGroup) *typescriptLogWriter {
+	return &typescriptLogWriter{
+		logger:                log.New(log.Writer(), "TSC - ", log.Flags()),
+		compilationDoneWaiter: compilationCompleteWaiter,
+	}
+}
+
+func (lw typescriptLogWriter) Write(p []byte) (n int, err error) {
+	// The input p can contain several lines and/or the partial line
+	// Code splits the input by EOL marker (\n) and stores the unfinished line
+	// for the next call to Write.
+	partialText := lw.unfinishedLine + string(p)
+	lines := strings.Split(partialText, "\n")
+	fullLines := lines
+	if strings.HasSuffix(partialText, "\n") {
+		lw.unfinishedLine = ""
+	} else {
+		fullLines = lines[:len(lines)-1]
+		lw.unfinishedLine = lines[len(lines)-1]
+	}
+	for _, fullLine := range fullLines {
+		text := strings.TrimSpace(fullLine)
+		if text == "" {
+			continue
+		}
+		if strings.HasSuffix(text, tsFileChangeDetectedMsg) ||
+			strings.HasSuffix(text, tsStartingCompilation) {
+			lw.compilationDoneWaiter.Add(1)
+		}
+		if tsStartWatchingMsg.MatchString(text) {
+			// A source code can be changed while previous compiler run is in progress.
+			// In this case typescript reruns compilation again almost immediately
+			// after the previous run finishes. To detect this situation, we are
+			// waiting waitForNextChangeInterval before decreasing the counter.
+			// If another compiler run is started in this interval, we will wait
+			// again until it finishes.
+			go func() {
+				time.Sleep(waitForNextChangeInterval)
+				lw.compilationDoneWaiter.Done()
+			}()
+		}
+		lw.logger.Print(text)
+	}
+	return len(p), nil
+}
+
+type typescriptInstance struct {
+	cmd                       *exec.Cmd
+	compilationCompleteWaiter *sync.WaitGroup
+}
+
+func newTypescriptInstance(tscBinaryPath string, projectPath string, outdir string) *typescriptInstance {
+	cmd := exec.Command(tscBinaryPath,
+		"--watch",
+		"--preserveWatchOutput",
+		"--project",
+		projectPath,
+		"--outDir",
+		outdir)
+
+	compilationCompleteWaiter := &sync.WaitGroup{}
+	logWriter := newTypescriptLogWriter(compilationCompleteWaiter)
+	// Note 1: (from https://golang.org/pkg/os/exec/#Cmd)
+	// If Stdout and Stderr are the same writer, and have a type that can
+	// be compared with ==, at most one goroutine at a time will call Write.
+	//
+	// Note 2: The typescript compiler reports all compilation errors to
+	// stdout by design (see https://github.com/microsoft/TypeScript/issues/615)
+	// It writes to stderr only when something unexpected happens (like internal
+	// exceptions). To print such errors in the same way as standard typescript
+	// error, the same logWriter is used both for Stdout and Stderr.
+	//
+	// If Stderr arrives in the middle of ordinary typescript output (i.e.
+	// something unexpected happens), the server.go can stop respond to http
+	// requests. However, this is not a problem for us: typescript compiler and
+	// server.go must be restarted anyway.
+	cmd.Stdout = logWriter
+	cmd.Stderr = logWriter
+
+	return &typescriptInstance{
+		cmd:                       cmd,
+		compilationCompleteWaiter: compilationCompleteWaiter,
+	}
+}
+
+func (ts *typescriptInstance) StartWatch() error {
+	err := ts.cmd.Start()
+	if err != nil {
+		return err
+	}
+	go func() {
+		ts.cmd.Wait()
+		log.Fatal("Typescript exits unexpected")
+	}()
+
+	return nil
+}
+
+func (ts *typescriptInstance) WaitForCompilationComplete() {
+	ts.compilationCompleteWaiter.Wait()
+}
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 12d39aa..2acd478 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -2,500 +2,854 @@
 # 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==
+"@babel/code-frame@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a"
+  integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==
   dependencies:
-    "@babel/highlight" "^7.8.3"
+    "@babel/highlight" "^7.10.4"
 
-"@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==
+"@babel/compat-data@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.10.4.tgz#706a6484ee6f910b719b696a9194f8da7d7ac241"
+  integrity sha512-t+rjExOrSVvjQQXNp5zAIYDp00KjdvGl/TpDX5REPr0S9IAIPQMTilcfG6q8c0QFmj9lSTVySV2VTsyggvtNIw==
   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"
+    browserslist "^4.12.0"
+    invariant "^2.2.4"
+    semver "^5.5.0"
+
+"@babel/core@^7.9.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.10.4.tgz#780e8b83e496152f8dd7df63892b2e052bf1d51d"
+  integrity sha512-3A0tS0HWpy4XujGc7QtOIHTeNwUgWaZc/WuS5YQrfhU67jnVmsD6OGPc1AKHH0LJHQICGncy3+YUjIhVlfDdcA==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/generator" "^7.10.4"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helpers" "^7.10.4"
+    "@babel/parser" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.1"
-    json5 "^2.1.0"
+    json5 "^2.1.2"
     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==
+"@babel/generator@^7.10.4", "@babel/generator@^7.4.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.10.4.tgz#e49eeed9fe114b62fa5b181856a43a5e32f5f243"
+  integrity sha512-toLIHUIAgcQygFZRAQcsLQV3CBuX6yOIru1kJk/qqqvcRmZrYe6WavZTSG+bB8MxhnL9YPf+pKQfuiP161q7ng==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
     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==
+"@babel/helper-annotate-as-pure@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3"
+  integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz#bb0b75f31bf98cbf9ff143c1ae578b87274ae1a3"
+  integrity sha512-L0zGlFrGWZK4PbT8AszSfLTM5sDU1+Az/En9VrdT8/LmEiJt4zXt+Jve9DCAnQcbqDhCI+29y/L93mrDzddCcg==
   dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-explode-assignable-expression" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-compilation-targets@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2"
+  integrity sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ==
   dependencies:
-    "@babel/helper-hoist-variables" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/compat-data" "^7.10.4"
+    browserslist "^4.12.0"
+    invariant "^2.2.4"
+    levenary "^1.1.1"
+    semver "^5.5.0"
 
-"@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==
+"@babel/helper-create-class-features-plugin@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.4.tgz#2d4015d0136bd314103a70d84a7183e4b344a355"
+  integrity sha512-9raUiOsXPxzzLjCXeosApJItoMnX3uyT4QdM2UldffuGApNrF8e938MwNpDCK9CPoyxrEoCgT+hObJc3mZa6lQ==
   dependencies:
-    "@babel/helper-regex" "^7.8.3"
-    regexpu-core "^4.6.0"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-member-expression-to-functions" "^7.10.4"
+    "@babel/helper-optimise-call-expression" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
 
-"@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==
+"@babel/helper-create-regexp-features-plugin@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8"
+  integrity sha512-2/hu58IEPKeoLF45DBwx3XFqsbCXmkdAay4spVr2x0jYgRxrSNp+ePwvSsy9g6YSaNDcKIQVPXk1Ov8S2edk2g==
   dependencies:
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-regex" "^7.10.4"
+    regexpu-core "^4.7.0"
+
+"@babel/helper-define-map@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.10.4.tgz#f037ad794264f729eda1889f4ee210b870999092"
+  integrity sha512-nIij0oKErfCnLUCWaCaHW0Bmtl2RO9cN7+u2QT8yqTywgALKlyUVOvHDElh+b5DwVC6YB1FOYFOTWcN/+41EDA==
+  dependencies:
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/types" "^7.10.4"
     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==
+"@babel/helper-explode-assignable-expression@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.10.4.tgz#40a1cd917bff1288f699a94a75b37a1a2dbd8c7c"
+  integrity sha512-4K71RyRQNPRrR85sr5QY4X3VwG4wtVoXZB9+L3r1Gp38DhELyHCtovqydRi7c1Ovb17eRGiQ/FD5s8JdU0Uy5A==
   dependencies:
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-function-name@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a"
+  integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==
   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.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-get-function-arity@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2"
+  integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-hoist-variables@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e"
+  integrity sha512-wljroF5PgCk2juF69kanHVs6vrLwIPNp6DLD+Lrl3hoQ3PpPPikaDRNFA+0t81NOoMt2DL6WW/mdU8k4k6ZzuA==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-member-expression-to-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.4.tgz#7cd04b57dfcf82fce9aeae7d4e4452fa31b8c7c4"
+  integrity sha512-m5j85pK/KZhuSdM/8cHUABQTAslV47OjfIB9Cc7P+PvlAoBzdb79BGNfw8RhT5Mq3p+xGd0ZfAKixbrUZx0C7A==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-module-imports@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620"
+  integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-module-transforms@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.10.4.tgz#ca1f01fdb84e48c24d7506bb818c961f1da8805d"
+  integrity sha512-Er2FQX0oa3nV7eM1o0tNCTx7izmQtwAQsIiaLRWtavAAEcskb0XJ5OjJbVrYXWOTr8om921Scabn4/tzlx7j1Q==
   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"
+    "@babel/helper-module-imports" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-simple-access" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.10.4"
     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==
+"@babel/helper-optimise-call-expression@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673"
+  integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@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-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375"
+  integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==
 
-"@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==
+"@babel/helper-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.4.tgz#59b373daaf3458e5747dece71bbaf45f9676af6d"
+  integrity sha512-inWpnHGgtg5NOF0eyHlC0/74/VkdRITY9dtTpB2PrxKKn+AkVMRiZz/Adrx+Ssg+MLDesi2zohBW6MVq6b4pOQ==
   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==
+"@babel/helper-remap-async-to-generator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.10.4.tgz#fce8bea4e9690bbe923056ded21e54b4e8b68ed5"
+  integrity sha512-86Lsr6NNw3qTNl+TBcF1oRZMaVzJtbWTyTko+CQL/tvNvcGYEFKbLXDPxtW0HKk3McNOk4KzY55itGWCAGK5tg==
   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-annotate-as-pure" "^7.10.4"
+    "@babel/helper-wrap-function" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-replace-supers@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz#d585cd9388ea06e6031e4cd44b6713cbead9e6cf"
+  integrity sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A==
   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-member-expression-to-functions" "^7.10.4"
+    "@babel/helper-optimise-call-expression" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-simple-access@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461"
+  integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw==
   dependencies:
-    "@babel/template" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helper-split-export-declaration@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.4.tgz#2c70576eaa3b5609b24cb99db2888cc3fc4251d1"
+  integrity sha512-pySBTeoUff56fL5CBU2hWm9TesA4r/rOkI9DyJLvvgz09MB9YtfIYe3iBriVaYNaPe+Alua0vBIOVOLs2buWhg==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@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/helper-validator-identifier@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2"
+  integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==
 
-"@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==
+"@babel/helper-wrap-function@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87"
+  integrity sha512-6py45WvEF0MhiLrdxtRjKjufwLL1/ob2qDJgg5JgNdojBAZSAKnAjkyOCNug6n+OBl4VW76XjvgSFTdaMcW0Ug==
   dependencies:
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@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==
+"@babel/helpers@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044"
+  integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA==
   dependencies:
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/highlight@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143"
+  integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.10.4"
     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/parser@^7.1.0", "@babel/parser@^7.10.4", "@babel/parser@^7.4.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.10.4.tgz#9eedf27e1998d87739fb5028a5120557c06a1a64"
+  integrity sha512-8jHII4hf+YVDsskTF6WuMB3X4Eh+PsUkC2ljq22so5rHvH+T8BzyL94VOdyFLNR8tBSVXOTbNHOKpR4TfRxVtA==
 
-"@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==
+"@babel/plugin-proposal-async-generator-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.4.tgz#4b65abb3d9bacc6c657aaa413e56696f9f170fc6"
+  integrity sha512-MJbxGSmejEFVOANAezdO39SObkURO5o/8b6fSH6D1pi9RZQt+ldppKPXfqgUWpSQ9asM6xaSaSJIaeWMDRP0Zg==
   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/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-remap-async-to-generator" "^7.10.4"
     "@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==
+"@babel/plugin-proposal-class-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807"
+  integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+    "@babel/helper-create-class-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.8.0":
+"@babel/plugin-proposal-dynamic-import@^7.10.4", "@babel/plugin-proposal-dynamic-import@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz#ba57a26cb98b37741e9d5bca1b8b0ddf8291f17e"
+  integrity sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.0"
+
+"@babel/plugin-proposal-json-strings@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz#593e59c63528160233bd321b1aebe0820c2341db"
+  integrity sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-json-strings" "^7.8.0"
+
+"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a"
+  integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0"
+
+"@babel/plugin-proposal-numeric-separator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz#ce1590ff0a65ad12970a609d78855e9a4c1aef06"
+  integrity sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
+
+"@babel/plugin-proposal-object-rest-spread@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.10.4.tgz#50129ac216b9a6a55b3853fdd923e74bf553a4c0"
+  integrity sha512-6vh4SqRuLLarjgeOf4EaROJAHjvu9Gl+/346PbDH9yWbJyfnJ/ah3jmYKYtswEyCoWZiidvVHjHshd4WgjB9BA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+    "@babel/plugin-transform-parameters" "^7.10.4"
+
+"@babel/plugin-proposal-optional-catch-binding@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd"
+  integrity sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.0"
+
+"@babel/plugin-proposal-optional-chaining@^7.10.4", "@babel/plugin-proposal-optional-chaining@^7.9.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.10.4.tgz#750f1255e930a1f82d8cdde45031f81a0d0adff7"
+  integrity sha512-ZIhQIEeavTgouyMSdZRap4VPPHqJJ3NEs2cuHs5p0erH+iz6khB0qfgU8g7UuJkG88+fBMy23ZiU+nuHvekJeQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.0"
+
+"@babel/plugin-proposal-private-methods@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909"
+  integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw==
+  dependencies:
+    "@babel/helper-create-class-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-proposal-unicode-property-regex@^7.10.4", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d"
+  integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@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":
+"@babel/plugin-syntax-class-properties@^7.10.4", "@babel/plugin-syntax-class-properties@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c"
+  integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-syntax-dynamic-import@^7.8.0", "@babel/plugin-syntax-dynamic-import@^7.8.3":
   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==
+"@babel/plugin-syntax-import-meta@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51"
+  integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.0":
+"@babel/plugin-syntax-json-strings@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a"
+  integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9"
+  integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-syntax-numeric-separator@^7.10.4", "@babel/plugin-syntax-numeric-separator@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97"
+  integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@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":
+"@babel/plugin-syntax-optional-catch-binding@^7.8.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==
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1"
+  integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-transform-async-to-generator@^7.0.0":
+"@babel/plugin-syntax-optional-chaining@^7.8.0", "@babel/plugin-syntax-optional-chaining@^7.8.3":
   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==
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a"
+  integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==
   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/helper-plugin-utils" "^7.8.0"
 
-"@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==
+"@babel/plugin-syntax-top-level-await@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz#4bbeb8917b54fcf768364e0a81f560e33a3ef57d"
+  integrity sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-arrow-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd"
+  integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-async-to-generator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz#41a5017e49eb6f3cda9392a51eef29405b245a37"
+  integrity sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ==
+  dependencies:
+    "@babel/helper-module-imports" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-remap-async-to-generator" "^7.10.4"
+
+"@babel/plugin-transform-block-scoped-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz#1afa595744f75e43a91af73b0d998ecfe4ebc2e8"
+  integrity sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-block-scoping@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.10.4.tgz#a670d1364bb5019a621b9ea2001482876d734787"
+  integrity sha512-J3b5CluMg3hPUii2onJDRiaVbPtKFPLEaV5dOPY5OeAbDi1iU/UbbFFTgwb7WnanaDy7bjU35kc26W3eM5Qa0A==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
     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==
+"@babel/plugin-transform-classes@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7"
+  integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA==
   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"
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-define-map" "^7.10.4"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-optimise-call-expression" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
     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==
+"@babel/plugin-transform-computed-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz#9ded83a816e82ded28d52d4b4ecbdd810cdfc0eb"
+  integrity sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-destructuring@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5"
+  integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-dotall-regex@^7.10.4", "@babel/plugin-transform-dotall-regex@^7.4.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee"
+  integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-duplicate-keys@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47"
+  integrity sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA==
   dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-exponentiation-operator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz#5ae338c57f8cf4001bdb35607ae66b92d665af2e"
+  integrity sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-for-of@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9"
+  integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ==
   dependencies:
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-function-name@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz#6a467880e0fc9638514ba369111811ddbe2644b7"
+  integrity sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-literals@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz#9f42ba0841100a135f22712d0e391c462f571f3c"
+  integrity sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-member-expression-literals@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz#b1ec44fcf195afcb8db2c62cd8e551c881baf8b7"
+  integrity sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw==
   dependencies:
-    "@babel/helper-module-transforms" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
-    babel-plugin-dynamic-import-node "^2.3.0"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-modules-amd@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.4.tgz#cb407c68b862e4c1d13a2fc738c7ec5ed75fc520"
+  integrity sha512-3Fw+H3WLUrTlzi3zMiZWp3AR4xadAEMv6XRCYnd5jAlLM61Rn+CRJaZMaNvIpcJpQ3vs1kyifYvEVPFfoSkKOA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-replace-supers" "^7.8.3"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    babel-plugin-dynamic-import-node "^2.3.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==
+"@babel/plugin-transform-modules-commonjs@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0"
+  integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w==
   dependencies:
-    "@babel/helper-call-delegate" "^7.8.3"
-    "@babel/helper-get-function-arity" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-simple-access" "^7.10.4"
+    babel-plugin-dynamic-import-node "^2.3.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==
+"@babel/plugin-transform-modules-systemjs@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.4.tgz#8f576afd943ac2f789b35ded0a6312f929c633f9"
+  integrity sha512-Tb28LlfxrTiOTGtZFsvkjpyjCl9IoaRI52AEU/VIwOwvDQWtbNJsAqTXzh+5R7i74e/OZHH2c2w2fsOqAfnQYQ==
   dependencies:
-    regenerator-transform "^0.14.0"
+    "@babel/helper-hoist-variables" "^7.10.4"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    babel-plugin-dynamic-import-node "^2.3.3"
 
-"@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==
+"@babel/plugin-transform-modules-umd@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz#9a8481fe81b824654b3a0b65da3df89f3d21839e"
+  integrity sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-named-capturing-groups-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz#78b4d978810b6f3bcf03f9e318f2fc0ed41aecb6"
+  integrity sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-new-target@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz#9097d753cb7b024cb7381a3b2e52e9513a9c6888"
+  integrity sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-regex" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-object-super@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz#d7146c4d139433e7a6526f888c667e314a093894"
+  integrity sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-parameters@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.4.tgz#7b4d137c87ea7adc2a0f3ebf53266871daa6fced"
+  integrity sha512-RurVtZ/D5nYfEg0iVERXYKEgDFeesHrHfx8RT05Sq57ucj2eOYAP6eu5fynL4Adju4I/mP/I6SO0DqNWAXjfLQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-get-function-arity" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-property-literals@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz#f6fe54b6590352298785b83edd815d214c42e3c0"
+  integrity sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-regenerator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz#2015e59d839074e76838de2159db421966fd8b63"
+  integrity sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw==
   dependencies:
-    "@babel/code-frame" "^7.8.3"
-    "@babel/parser" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    regenerator-transform "^0.14.2"
 
-"@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==
+"@babel/plugin-transform-reserved-words@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz#8f2682bcdcef9ed327e1b0861585d7013f8a54dd"
+  integrity sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ==
   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"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-shorthand-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6"
+  integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-spread@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.10.4.tgz#4e2c85ea0d6abaee1b24dcfbbae426fe8d674cff"
+  integrity sha512-1e/51G/Ni+7uH5gktbWv+eCED9pP8ZpRhZB3jOaI3mmzfvJTWHkuyYTv0Z5PYtyM+Tr2Ccr9kUdQxn60fI5WuQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-sticky-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d"
+  integrity sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-regex" "^7.10.4"
+
+"@babel/plugin-transform-template-literals@^7.10.4", "@babel/plugin-transform-template-literals@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.4.tgz#e6375407b30fcb7fcfdbba3bb98ef3e9d36df7bc"
+  integrity sha512-4NErciJkAYe+xI5cqfS8pV/0ntlY5N5Ske/4ImxAVX7mk9Rxt2bwDTGv1Msc2BRJvWQcmYEC+yoMLdX22aE4VQ==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-typeof-symbol@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz#9509f1a7eec31c4edbffe137c16cc33ff0bc5bfc"
+  integrity sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-unicode-escapes@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007"
+  integrity sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-unicode-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz#e56d71f9282fac6db09c82742055576d5e6d80a8"
+  integrity sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/preset-env@^7.9.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.10.4.tgz#fbf57f9a803afd97f4f32e4f798bb62e4b2bef5f"
+  integrity sha512-tcmuQ6vupfMZPrLrc38d0sF2OjLT3/bZ0dry5HchNCQbrokoQi4reXqclvkkAT5b+gWc23meVWpve5P/7+w/zw==
+  dependencies:
+    "@babel/compat-data" "^7.10.4"
+    "@babel/helper-compilation-targets" "^7.10.4"
+    "@babel/helper-module-imports" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-proposal-async-generator-functions" "^7.10.4"
+    "@babel/plugin-proposal-class-properties" "^7.10.4"
+    "@babel/plugin-proposal-dynamic-import" "^7.10.4"
+    "@babel/plugin-proposal-json-strings" "^7.10.4"
+    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4"
+    "@babel/plugin-proposal-numeric-separator" "^7.10.4"
+    "@babel/plugin-proposal-object-rest-spread" "^7.10.4"
+    "@babel/plugin-proposal-optional-catch-binding" "^7.10.4"
+    "@babel/plugin-proposal-optional-chaining" "^7.10.4"
+    "@babel/plugin-proposal-private-methods" "^7.10.4"
+    "@babel/plugin-proposal-unicode-property-regex" "^7.10.4"
+    "@babel/plugin-syntax-async-generators" "^7.8.0"
+    "@babel/plugin-syntax-class-properties" "^7.10.4"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.0"
+    "@babel/plugin-syntax-json-strings" "^7.8.0"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0"
+    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.0"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.0"
+    "@babel/plugin-syntax-top-level-await" "^7.10.4"
+    "@babel/plugin-transform-arrow-functions" "^7.10.4"
+    "@babel/plugin-transform-async-to-generator" "^7.10.4"
+    "@babel/plugin-transform-block-scoped-functions" "^7.10.4"
+    "@babel/plugin-transform-block-scoping" "^7.10.4"
+    "@babel/plugin-transform-classes" "^7.10.4"
+    "@babel/plugin-transform-computed-properties" "^7.10.4"
+    "@babel/plugin-transform-destructuring" "^7.10.4"
+    "@babel/plugin-transform-dotall-regex" "^7.10.4"
+    "@babel/plugin-transform-duplicate-keys" "^7.10.4"
+    "@babel/plugin-transform-exponentiation-operator" "^7.10.4"
+    "@babel/plugin-transform-for-of" "^7.10.4"
+    "@babel/plugin-transform-function-name" "^7.10.4"
+    "@babel/plugin-transform-literals" "^7.10.4"
+    "@babel/plugin-transform-member-expression-literals" "^7.10.4"
+    "@babel/plugin-transform-modules-amd" "^7.10.4"
+    "@babel/plugin-transform-modules-commonjs" "^7.10.4"
+    "@babel/plugin-transform-modules-systemjs" "^7.10.4"
+    "@babel/plugin-transform-modules-umd" "^7.10.4"
+    "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4"
+    "@babel/plugin-transform-new-target" "^7.10.4"
+    "@babel/plugin-transform-object-super" "^7.10.4"
+    "@babel/plugin-transform-parameters" "^7.10.4"
+    "@babel/plugin-transform-property-literals" "^7.10.4"
+    "@babel/plugin-transform-regenerator" "^7.10.4"
+    "@babel/plugin-transform-reserved-words" "^7.10.4"
+    "@babel/plugin-transform-shorthand-properties" "^7.10.4"
+    "@babel/plugin-transform-spread" "^7.10.4"
+    "@babel/plugin-transform-sticky-regex" "^7.10.4"
+    "@babel/plugin-transform-template-literals" "^7.10.4"
+    "@babel/plugin-transform-typeof-symbol" "^7.10.4"
+    "@babel/plugin-transform-unicode-escapes" "^7.10.4"
+    "@babel/plugin-transform-unicode-regex" "^7.10.4"
+    "@babel/preset-modules" "^0.1.3"
+    "@babel/types" "^7.10.4"
+    browserslist "^4.12.0"
+    core-js-compat "^3.6.2"
+    invariant "^2.2.2"
+    levenary "^1.1.1"
+    semver "^5.5.0"
+
+"@babel/preset-modules@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.3.tgz#13242b53b5ef8c883c3cf7dddd55b36ce80fbc72"
+  integrity sha512-Ra3JXOHBq2xd56xSF7lMKXdjBn3T772Y1Wet3yWnkDly9zHvJki029tAFzvAAK5cf4YV3yoxuP61crYRol6SVg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/plugin-proposal-unicode-property-regex" "^7.4.4"
+    "@babel/plugin-transform-dotall-regex" "^7.4.4"
+    "@babel/types" "^7.4.4"
+    esutils "^2.0.2"
+
+"@babel/runtime@^7.8.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.4.tgz#a6724f1a6b8d2f6ea5236dbfe58c7d7ea9c5eb99"
+  integrity sha512-UpTN5yUJr9b4EX2CnGNWIvER7Ab83ibv0pcvvHc4UOdrBI5jb8bj+32cCwPX6xu0mt2daFNjYhoi+X7beH0RSw==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
+"@babel/template@^7.10.4", "@babel/template@^7.4.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
+  integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/parser" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/traverse@^7.10.4", "@babel/traverse@^7.4.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.10.4.tgz#e642e5395a3b09cc95c8e74a27432b484b697818"
+  integrity sha512-aSy7p5THgSYm4YyxNGz6jZpXf+Ok40QF3aA2LyIONkDHpAcJzDUqlCKXv6peqYUs2gmic849C/t2HKw2a2K20Q==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/generator" "^7.10.4"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
+    "@babel/parser" "^7.10.4"
+    "@babel/types" "^7.10.4"
     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==
+"@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.10.4.tgz#369517188352e18219981efd156bfdb199fff1ee"
+  integrity sha512-UTCFOxC3FsFHb7lkRMVvgLzaRVamXuAs2Tz4wajva4WxtVY82eZeaUBtC2Zt95FU9TiznuC0Zk35tsim8jeVpg==
   dependencies:
-    esutils "^2.0.2"
+    "@babel/helper-validator-identifier" "^7.10.4"
     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==
+"@koa/cors@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.1.0.tgz#618bb073438cfdbd3ebd0e648a76e33b84f3a3b2"
+  integrity sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==
+  dependencies:
+    vary "^1.1.2"
+
+"@open-wc/building-utils@^2.18.0":
+  version "2.18.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.18.0.tgz#f80929dfcfb6d8a6cb5c933654c721808b4bb2d3"
+  integrity sha512-U1n8sLQlLt3IuqhU7tDsGQAGUfVMiB64xJsAmJEtekposrjqkjtRLU/WipvROl1A2GTsrMojMjNbFqzJghpd6g==
+  dependencies:
+    "@babel/core" "^7.9.0"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.3"
+    "@webcomponents/shadycss" "^1.9.4"
+    "@webcomponents/webcomponentsjs" "^2.4.0"
+    arrify "^2.0.1"
+    browserslist "^4.9.1"
+    chokidar "^3.0.0"
+    clean-css "^4.2.1"
+    clone "^2.1.2"
+    core-js-bundle "^3.6.0"
+    deepmerge "^4.2.2"
+    es-module-shims "^0.4.6"
+    html-minifier "^4.0.0"
+    lru-cache "^5.1.1"
+    minimatch "^3.0.4"
+    parse5 "^5.1.1"
+    path-is-inside "^1.0.2"
+    regenerator-runtime "^0.13.3"
+    resolve "^1.11.1"
+    rimraf "^3.0.2"
+    shady-css-scoped-element "^0.0.2"
+    systemjs "^6.3.1"
+    terser "^4.6.7"
+    valid-url "^1.0.9"
+    whatwg-fetch "^3.0.0"
+    whatwg-url "^7.0.0"
+
+"@open-wc/karma-esm@^2.16.16":
+  version "2.16.16"
+  resolved "https://registry.yarnpkg.com/@open-wc/karma-esm/-/karma-esm-2.16.16.tgz#6ebff57f249e95f777b7e04782ef08ed41e22f53"
+  integrity sha512-IALT10JfwK+h7T0hGKTUliGdkWzQbyQg195D+RfUteIoTof6Z5+dBp7JUh2fQygIyNj7IIYHJ9ej816QlgHjdA==
+  dependencies:
+    "@open-wc/building-utils" "^2.18.0"
+    babel-plugin-istanbul "^5.1.4"
+    chokidar "^3.0.0"
+    deepmerge "^4.2.2"
+    es-dev-server "^1.56.0"
+    minimatch "^3.0.4"
+    node-fetch "^2.6.0"
+    polyfills-loader "^1.6.1"
+    portfinder "^1.0.21"
+    request "^2.88.0"
 
 "@polymer/iron-test-helpers@^3.0.1":
   version "3.0.1"
@@ -511,100 +865,144 @@
   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@^4.0.2":
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-4.0.2.tgz#2f4777ecdcfb22ee000db35a05e0edf27c722c19"
+  integrity sha512-tLX8tFE4mkc4p84YG5239G0hbgTVv2irZYrSyO0OblUqIRbRoCPmbydm3HRFQkJeAB3rPCtyeZ2roJULsmTG3A==
 
-"@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==
+"@rollup/plugin-node-resolve@^7.1.1":
+  version "7.1.3"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz#80de384edfbd7bfc9101164910f86078151a3eca"
+  integrity sha512-RxtSL3XmdTAE2byxekYLnx+98kEUOrPHF/KRVjLH+DEIHy6kjIw7YINQzn+NXiH/NTrQLAwYs0GWB+csWygA9Q==
   dependencies:
-    "@types/babel-types" "*"
+    "@rollup/pluginutils" "^3.0.8"
+    "@types/resolve" "0.0.8"
+    builtin-modules "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.14.2"
 
-"@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==
+"@rollup/pluginutils@^3.0.0", "@rollup/pluginutils@^3.0.8":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
+  integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
   dependencies:
-    "@types/babel-types" "*"
+    "@types/estree" "0.0.39"
+    estree-walker "^1.0.1"
+    picomatch "^2.2.2"
 
-"@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==
+"@sinonjs/commons@^1", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.7.2":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.0.tgz#c8d68821a854c555bba172f3b06959a0039b236d"
+  integrity sha512-wEj54PfsZ5jGSwMX68G8ZXFawcSglQSXqCftWX3ec8MDUzQdHgcKvw97awHbY0efQEL5iKUOAmmVtoYgmrSG4Q==
   dependencies:
-    "@types/babel-types" "*"
+    type-detect "4.0.8"
 
-"@types/bluebird@*":
-  version "3.5.29"
-  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.29.tgz#7cd933c902c4fc83046517a1bef973886d00bdb6"
-  integrity sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==
+"@sinonjs/fake-timers@^6.0.0", "@sinonjs/fake-timers@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40"
+  integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+
+"@sinonjs/formatio@^5.0.1":
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-5.0.1.tgz#f13e713cb3313b1ab965901b01b0828ea6b77089"
+  integrity sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==
+  dependencies:
+    "@sinonjs/commons" "^1"
+    "@sinonjs/samsam" "^5.0.2"
+
+"@sinonjs/samsam@^5.0.2", "@sinonjs/samsam@^5.0.3":
+  version "5.0.3"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.0.3.tgz#86f21bdb3d52480faf0892a480c9906aa5a52938"
+  integrity sha512-QucHkc2uMJ0pFGjJUDP3F9dq5dx8QIaqISl9QgwLOh6P9yv877uONPGXh/OH/0zmM3tW1JjuJltAZV2l7zU+uQ==
+  dependencies:
+    "@sinonjs/commons" "^1.6.0"
+    lodash.get "^4.4.2"
+    type-detect "^4.0.8"
+
+"@sinonjs/text-encoding@^0.7.1":
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
+  integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
+
+"@types/accepts@*":
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
+  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/babel__core@^7.1.3":
+  version "7.1.9"
+  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d"
+  integrity sha512-sY2RsIJ5rpER1u3/aQ8OFSI7qGIy8o1NEEbgb2UaJcvOtXOMpd39ko723NBpjQFg9SIX7TXtjejZVGeIMLhoOw==
+  dependencies:
+    "@babel/parser" "^7.1.0"
+    "@babel/types" "^7.0.0"
+    "@types/babel__generator" "*"
+    "@types/babel__template" "*"
+    "@types/babel__traverse" "*"
+
+"@types/babel__generator@*":
+  version "7.6.1"
+  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.1.tgz#4901767b397e8711aeb99df8d396d7ba7b7f0e04"
+  integrity sha512-bBKm+2VPJcMRVwNhxKu8W+5/zT7pwNEqeokFOmbvVSqGzFneNxYcEBro9Ac7/N9tlsaPYnZLK8J1LWKkMsLAew==
+  dependencies:
+    "@babel/types" "^7.0.0"
+
+"@types/babel__template@*":
+  version "7.0.2"
+  resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.0.2.tgz#4ff63d6b52eddac1de7b975a5223ed32ecea9307"
+  integrity sha512-/K6zCpeW7Imzgab2bLkLEbz0+1JlFSrUMdw7KoIIu+IUdu51GWaBZpd3y1VXGVXzynvGa4DaIaxNZHiON3GXUg==
+  dependencies:
+    "@babel/parser" "^7.1.0"
+    "@babel/types" "^7.0.0"
+
+"@types/babel__traverse@*":
+  version "7.0.12"
+  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.0.12.tgz#22f49a028e69465390f87bb103ebd61bd086b8f5"
+  integrity sha512-t4CoEokHTfcyfb4hUaF9oOHu9RmmNWnm1CP0YmMqOOfClKascOmvlEM736vlqeScuGvBDsHkf8R2INd4DWreQA==
+  dependencies:
+    "@babel/types" "^7.3.0"
 
 "@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==
+  version "1.19.0"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
+  integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==
   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/browserslist-useragent@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/browserslist-useragent/-/browserslist-useragent-3.0.0.tgz#d425c9818182ce71ce53866798cee9c7d41d6e53"
+  integrity sha512-ZBvKzg3yyWNYEkwxAzdmUzp27sFvw+1m080/+2lwrt+eltNefn1f4fnpMyrjOla31p8zLleCYqQXw+3EETfn0w==
 
-"@types/chai@*":
-  version "4.2.7"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.7.tgz#1c8c25cbf6e59ffa7d6b9652c78e547d9a41692d"
-  integrity sha512-luq8meHGYwvky0O7u0eQZdA7B4Wd9owUCqvbw2m3XCrCU8mplYOujMBbvyS547AxJkC+pGnd0Cm15eNxEUNU8g==
+"@types/browserslist@^4.8.0":
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/@types/browserslist/-/browserslist-4.8.0.tgz#60489aefdf0fcb56c2d8eb65267ff08dad7a526d"
+  integrity sha512-4PyO9OM08APvxxo1NmQyQKlJdowPCOQIy5D/NLO3aO0vGC57wsMptvGp3b8IbYnupFZr92l1dlVief1JvS6STQ==
 
-"@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/caniuse-api@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.0.tgz#af31cc52062be0ab24583be072fd49b634dcc2fe"
+  integrity sha512-wT1VfnScjAftZsvLYaefu/UuwYJdYBwD2JDL2OQd01plGmuAoir5V6HnVHgrfh7zEwcasoiyO2wQ+W58sNh2sw==
 
-"@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/chai@^4.2.14":
+  version "4.2.14"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.14.tgz#44d2dd0b5de6185089375d976b4ec5caf6861193"
+  integrity sha512-G+ITQPXkwTrslfG5L/BksmbLUA0M1iybEsmCWPqzSxsRRhJZimBKJkoMi8fr/CPygPTj4zO5pJH7I2/cm9M7SQ==
 
-"@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/command-line-args@^5.0.0":
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.0.0.tgz#484e704d20dbb8754a8f091eee45cdd22bcff28c"
+  integrity sha512-4eOPXyn5DmP64MCMF8ePDvdlvlzt2a+F8ZaVjqmh2yFCpGjc1kI3kGnCFYX9SCsGTjQcWIyVZ86IHCEyjy/MNg==
 
-"@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/command-line-usage@^5.0.1":
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.1.tgz#99424950da567ba67b6b65caee57ff03c4e751ec"
+  integrity sha512-/xUgezxxYePeXhg5S04hUjxG9JZi+rJTs1+4NwpYPfSaS7BeDa6tVJkH6lN9Cb6rl8d24Fi2uX0s0Ngg2JT6gg==
 
 "@types/connect@*":
   version "3.4.33"
@@ -613,242 +1011,196 @@
   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/content-disposition@*":
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.3.tgz#0aa116701955c2faa0717fc69cd1596095e49d96"
+  integrity sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg==
 
-"@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/cookies@*":
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.4.tgz#26dedf791701abc0e36b5b79a5722f40e455f87b"
+  integrity sha512-oTGtMzZZAVuEjTwCjIh8T8FrC8n/uwy+PG0yTvQcdZ7etoel7C7/3MSd7qrukENTgQtotG7gvBlBojuVs7X5rw==
+  dependencies:
+    "@types/connect" "*"
+    "@types/express" "*"
+    "@types/keygrip" "*"
+    "@types/node" "*"
 
-"@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/debounce@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.0.tgz#9ee99259f41018c640b3929e1bb32c3dcecdb192"
+  integrity sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw==
 
-"@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/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==
+"@types/etag@*":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@types/etag/-/etag-1.8.0.tgz#37f0b1f3ea46da7ae319bbedb607e375b4c99f7e"
+  integrity sha512-EdSN0x+Y0/lBv7YAb8IU4Jgm6DWM+Bqtz7o5qozl96fzaqdqbdfHS5qjdpFeIv7xQ8jSLyjMMNShgYtMajEHyQ==
   dependencies:
     "@types/node" "*"
+
+"@types/express-serve-static-core@*":
+  version "4.17.8"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.8.tgz#b8f7b714138536742da222839892e203df569d1c"
+  integrity sha512-1SJZ+R3Q/7mLkOD9ewCBDYD2k0WyZQtWYqF/2VvoNN2/uhI49J9CDN4OAm+wGMA0DbArA4ef27xl4+JwMtGggw==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
     "@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==
+"@types/express@*":
+  version "4.17.6"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.6.tgz#6bce49e49570507b86ea1b07b806f04697fac45e"
+  integrity sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==
   dependencies:
     "@types/body-parser" "*"
     "@types/express-serve-static-core" "*"
+    "@types/qs" "*"
     "@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/http-assert@*":
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b"
+  integrity sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==
 
-"@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==
+"@types/keygrip@*":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
+  integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==
+
+"@types/koa-compose@*":
+  version "3.2.5"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d"
+  integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==
   dependencies:
-    "@types/glob" "*"
+    "@types/koa" "*"
+
+"@types/koa-compress@^2.0.9":
+  version "2.0.9"
+  resolved "https://registry.yarnpkg.com/@types/koa-compress/-/koa-compress-2.0.9.tgz#5d19f7d928f78b451a9afd148863e2b45f51e541"
+  integrity sha512-1Sa9OsbHd2N2N7gLpdIRHe8W99EZbfIR31D7Iisx16XgwZCnWUtGXzXQejhu74Y1pE/wILqBP6VL49ch/MVpZw==
+  dependencies:
+    "@types/koa" "*"
     "@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==
+"@types/koa-etag@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/koa-etag/-/koa-etag-3.0.0.tgz#d14d3dab45d5577b94bc72960631de96751341d3"
+  integrity sha512-gXQUtKGEnCy0sZLG+uE3wL4mvY1CBPcb6ECjpAoD8RGYy/8ACY1B084k8LTFPIdVcmy7GD6Y4n3up3jnupofcQ==
   dependencies:
-    "@types/events" "*"
-    "@types/minimatch" "*"
+    "@types/etag" "*"
+    "@types/koa" "*"
+
+"@types/koa-send@*":
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/@types/koa-send/-/koa-send-4.1.2.tgz#978f8267ad116d12ac6a18fecd8f34c5657e09ad"
+  integrity sha512-rfqKIv9bFds39Jxvsp8o3YJLnEQVPVriYA14AuO2OY65IHh/4UX4U/iMs5L0wATpcRmm1bbe0BNk23TRwx3VQQ==
+  dependencies:
+    "@types/koa" "*"
+
+"@types/koa-static@^4.0.1":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/koa-static/-/koa-static-4.0.1.tgz#b740d80a549b0a0a7a3b38918daecde88a7a50ec"
+  integrity sha512-SSpct5fEcAeRkBHa3RiwCIRfDHcD1cZRhwRF///ZfvRt8KhoqRrhK6wpDlYPk/vWHVFE9hPGqh68bhzsHkir4w==
+  dependencies:
+    "@types/koa" "*"
+    "@types/koa-send" "*"
+
+"@types/koa@*", "@types/koa@^2.0.48":
+  version "2.11.3"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.11.3.tgz#540ece376581b12beadf9a417dd1731bc31c16ce"
+  integrity sha512-ABxVkrNWa4O/Jp24EYI/hRNqEVRlhB9g09p48neQp4m3xL1TJtdWk2NyNQSMCU45ejeELMQZBYyfstyVvO2H3Q==
+  dependencies:
+    "@types/accepts" "*"
+    "@types/content-disposition" "*"
+    "@types/cookies" "*"
+    "@types/http-assert" "*"
+    "@types/keygrip" "*"
+    "@types/koa-compose" "*"
     "@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==
+"@types/koa__cors@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.0.1.tgz#a8cf8535f0fe682c9421f1b9379837c585f8b66b"
+  integrity sha512-loqZNXliley8kncc4wrX9KMqLGN6YfiaO3a3VFX+yVkkXJwOrZU4lipdudNjw5mFyC+5hd7h9075hQWcVVpeOg==
   dependencies:
-    "@types/node" "*"
-    "@types/vinyl" "*"
+    "@types/koa" "*"
 
-"@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/lodash@^4.14.162":
+  version "4.14.162"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.162.tgz#65d78c397e0d883f44afbf1f7ba9867022411470"
+  integrity sha512-alvcho1kRUnnD1Gcl4J+hK0eencvzq9rmzvFPRmP5rPHx9VVsJj6bKLTATPVf9ktgv4ujzh7T+XWKp+jhuODig==
 
-"@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/lru-cache@^5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
+  integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==
 
-"@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@*":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.2.tgz#857a118d8634c84bba7ae14088e4508490cd5da5"
+  integrity sha512-4kPlzbljFcsttWEq6aBW0OZe6BDajAmyvr2xknBG92tejQnvdGtT9+kXSZ580DqpxY9qG2xeQVF9Dq0ymUTo5Q==
 
-"@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":
+"@types/minimatch@^3.0.3":
   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/mocha@^8.0.3":
+  version "8.0.3"
+  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.0.3.tgz#51b21b6acb6d1b923bbdc7725c38f9f455166402"
+  integrity sha512-vyxR57nv8NfcU0GZu8EUXZLTbCMupIUwy95LJ6lllN+JRPG25CwMHoB1q5xKh8YKhQnHYRAn4yW2yuHbf/5xgg==
 
 "@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" "*"
+  version "14.0.14"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.14.tgz#24a0b5959f16ac141aeb0c5b3cd7a15b7c64cbce"
+  integrity sha512-syUgf67ZQpaJj01/tRTknkMNoBBLWJOBODF0Zm4NrXmiSuxjymFrxnTu1QVYRubhVkRcZLYZG8STTwJRdVm/WQ==
 
 "@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/qs@*":
+  version "6.9.3"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.3.tgz#b755a0934564a200d3efdf88546ec93c369abd03"
+  integrity sha512-7s9EQWupR1fTc2pSMtXRQ9w9gLOcrJn+h7HOXw4evxyvVqMi4f+q7d2tnFe3ng3SNHjtK+0EzGMGFUQX4/AQRA==
 
 "@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==
+"@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/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==
+"@types/serve-static@*":
+  version "1.13.4"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.4.tgz#6662a93583e5a6cabca1b23592eb91e12fa80e7c"
+  integrity sha512-jTDt0o/YbpNwZbQmE/+2e+lfjJEJJR0I3OFaKQKPWkASkCoW3i6fsUnqudSMcNAfbtmADGu8f4MV4q+GqULmug==
   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==
+"@types/sinon@^9.0.8":
+  version "9.0.8"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-9.0.8.tgz#1ed0038d356784f75b086104ef83bfd4130bb81b"
+  integrity sha512-IVnI820FZFMGI+u1R+2VdRaD/82YIQTdqLYC9DLPszZuynAJDtCvCtCs3bmyL66s7FqRM3+LPX7DhHnVTaagDw==
   dependencies:
-    "@types/node" "*"
+    "@types/sinonjs__fake-timers" "*"
 
-"@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/sinonjs__fake-timers@*":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae"
+  integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==
 
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
@@ -857,27 +1209,27 @@
   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/shadycss@^1.9.4":
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.10.0.tgz#7a80ec1e8b271fb3f0cc02cd4358b877a303545d"
+  integrity sha512-UMS+dF4DXDrcUmQqK6aLd/3mFyfGktKG/hZR6FtrsQK/INO07G0H8FxElLkuvHj0iePeZGpR7R4lWFTvX7rc9g==
 
-"@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==
+"@webcomponents/webcomponentsjs@^2.4.0":
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.4.3.tgz#384f4f6d54563ba465fb4df21fe89e78a76fc530"
+  integrity sha512-cV4+sAmshf8ysU2USutrSRYQkJzEYKHsRCGa0CkMElGpG5747VHtkfsW3NdVIBV/m2MDKXTDydT4lkrysH7IFA==
 
-accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
+abortcontroller-polyfill@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.4.0.tgz#0d5eb58e522a461774af8086414f68e1dda7a6c4"
+  integrity sha512-3ZFfCRfDzx3GFjO6RAkYx81lPGpUS20ISxux9gLxuKnqafNcFQo59+IoZqpO2WvQlyc287B62HDnDdNYRmlvWA==
+
+accepts@^1.3.5, accepts@~1.3.4:
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
   integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
@@ -890,72 +1242,26 @@
   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==
+  version "6.12.2"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd"
+  integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==
   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"
@@ -966,11 +1272,6 @@
   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"
@@ -978,49 +1279,18 @@
   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:
+any-promise@^1.0.0, any-promise@^1.1.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:
+anymatch@~3.1.1:
   version "3.1.1"
-  resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
-  integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142"
+  integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==
   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"
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
 
 argparse@^1.0.7:
   version "1.0.10"
@@ -1029,65 +1299,26 @@
   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=
+array-back@^4.0.0, array-back@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.1.tgz#9b80312935a52062e1a233a9c7abeb5481b30e90"
+  integrity sha512-Z/JnaVEXv+A9xabHzN43FiiiWEE7gPCRXMrVmRm00tWbjZRul1iHm7ECzlyNq1p4a4ATXz+G9FJ3GqGOkOV3fg==
 
 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@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
+  integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
+
 asn1@~0.2.3:
   version "0.2.4"
   resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
@@ -1100,336 +1331,54 @@
   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:
+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:
+async@^2.6.2:
   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==
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.10.0.tgz#a17b3a8ea811060e74d47d306122400ad4497ae2"
+  integrity sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==
 
-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==
+babel-plugin-dynamic-import-node@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
+  integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==
   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==
+babel-plugin-istanbul@^5.1.4:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz#df4ade83d897a92df069c4d9a25cf2671293c854"
+  integrity sha512-5LphC0USA8t4i1zCtjbbNb6jJj/9+X6P37Qfirc/70EQ34xKlMW+a1RHGwxGI+SwWpNwZ27HqvzAobeqaXwiZw==
   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==
+    "@babel/helper-plugin-utils" "^7.0.0"
+    find-up "^3.0.0"
+    istanbul-lib-instrument "^3.3.0"
+    test-exclude "^5.2.3"
 
 backo2@1.0.2:
   version "1.0.2"
@@ -1446,33 +1395,10 @@
   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"
+base64id@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
+  integrity sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=
 
 bcrypt-pbkdf@^1.0.0:
   version "1.0.2"
@@ -1488,27 +1414,22 @@
   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"
+binary-extensions@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c"
+  integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==
 
 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:
+bluebird@^3.3.0:
+  version "3.7.2"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
+  integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
+
+body-parser@^1.16.1:
   version "1.19.0"
   resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
   integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
@@ -1524,30 +1445,6 @@
     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"
@@ -1556,113 +1453,84 @@
     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=
+braces@^3.0.2, braces@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
+  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
   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=
+    fill-range "^7.0.1"
 
 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==
+browserslist-useragent@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/browserslist-useragent/-/browserslist-useragent-3.0.3.tgz#d06c062a4e444ad5e1a80323131d4508450c9af5"
+  integrity sha512-8KKO6kOXu/93IkMi8zVqzU72BgpoxcITIHtkM1qmlnxJtIMF9Y+2uWL9JS2uUbzj/PaS3kaA6LcICBThMojGjA==
   dependencies:
-    https-proxy-agent "^2.2.1"
+    browserslist "^4.12.0"
+    semver "^7.3.2"
+    useragent "^2.3.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=
+browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5, browserslist@^4.9.1:
+  version "4.12.2"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.12.2.tgz#76653d7e4c57caa8a1a28513e2f4e197dc11a711"
+  integrity sha512-MfZaeYqR8StRZdstAK9hCKDd2StvePCYp5rHzQCPicUjfFliDgmuaBNPHYUTpAywBN8+Wc/d7NYVFkO0aqaBUw==
+  dependencies:
+    caniuse-lite "^1.0.30001088"
+    electron-to-chromium "^1.3.483"
+    escalade "^3.0.1"
+    node-releases "^1.1.58"
+
+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-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:
+bytes@3.1.0, bytes@^3.0.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:
+cache-content-type@^1.0.0:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
-  integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==
+  resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
+  integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==
   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"
+    mime-types "^2.1.18"
+    ylru "^1.2.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:
+camel-case@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
   integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=
@@ -1670,55 +1538,31 @@
     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:
+camelcase@^5.0.0, camelcase@^5.3.1:
   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=
+caniuse-api@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
+  integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==
   dependencies:
-    "@types/node" "^4.0.30"
+    browserslist "^4.0.0"
+    caniuse-lite "^1.0.0"
+    lodash.memoize "^4.1.2"
+    lodash.uniq "^4.5.0"
 
-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==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001088:
+  version "1.0.30001093"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001093.tgz#833e80f64b1a0455cbceed2a4a3baf19e4abd312"
+  integrity sha512-0+ODNoOjtWD5eS9aaIpf4K0gQqZfILNY4WSNuYzeT1sXni+lMrrVjc0odEobJt6wrODofDZUX8XYi/5y7+xl8g==
 
 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"
@@ -1731,18 +1575,7 @@
     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:
+chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, 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==
@@ -1751,57 +1584,48 @@
     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==
+chokidar@3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6"
+  integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==
   dependencies:
-    arr-union "^3.1.0"
-    define-property "^0.2.5"
-    isobject "^3.0.0"
-    static-extend "^0.1.1"
+    anymatch "~3.1.1"
+    braces "~3.0.2"
+    glob-parent "~5.1.0"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.2.0"
+  optionalDependencies:
+    fsevents "~2.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==
+chokidar@^3.0.0:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450"
+  integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==
+  dependencies:
+    anymatch "~3.1.1"
+    braces "~3.0.2"
+    glob-parent "~5.1.0"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.3.0"
+  optionalDependencies:
+    fsevents "~2.1.2"
+
+clean-css@^4.2.1:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
+  integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==
   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"
@@ -1811,30 +1635,17 @@
     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:
+clone@^2.1.2:
   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"
+co@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+  integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
 
-color-convert@^1.9.0, color-convert@^1.9.1:
+color-convert@^1.9.0:
   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==
@@ -1846,45 +1657,11 @@
   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:
+colors@^1.1.0:
   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"
@@ -1902,38 +1679,21 @@
     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==
+command-line-usage@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.0.tgz#f28376a3da3361ff3d36cfd31c3c22c9a64c7cb6"
+  integrity sha512-Ew1clU4pkUeo6AFVDFxCbnN7GIZfXl48HIOQeFQnkO3oOqvpI7wdqtLRwv9iOCZ/7A+z4csVZeiDdEcj8g6Wiw==
   dependencies:
-    array-back "^2.0.0"
-    chalk "^2.4.1"
-    table-layout "^0.4.3"
-    typical "^2.6.1"
+    array-back "^4.0.0"
+    chalk "^2.4.2"
+    table-layout "^1.0.0"
+    typical "^5.2.0"
 
-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:
+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==
-
 component-bind@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
@@ -1944,204 +1704,87 @@
   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:
+compressible@^2.0.0:
   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==
+connect@^3.6.0:
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8"
+  integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==
   dependencies:
-    buffer-from "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^2.2.2"
-    typedarray "^0.0.6"
+    debug "2.6.9"
+    finalhandler "1.1.2"
+    parseurl "~1.3.3"
+    utils-merge "1.0.1"
 
-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:
+content-disposition@~0.5.2:
   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:
+content-type@^1.0.4, 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:
+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==
+cookies@~0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
+  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+  dependencies:
+    depd "~2.0.0"
+    keygrip "~1.1.0"
 
-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-bundle@^3.6.0:
+  version "3.6.5"
+  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.6.5.tgz#3a425ad66ad19aeefea89acfd48cff674ff58590"
+  integrity sha512-awf49McIBT3sDXceSex69w/i7PMXQwxI4ZqknCtaYbW4Q0u0HUZiaQLlPD6pU2nFBofIowgWIS1ANgHjqnQu4Q==
 
-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-js-compat@^3.6.2:
+  version "3.6.5"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.6.5.tgz#2a51d9a4e25dfd6e690251aa81f99e3c05481f1c"
+  integrity sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng==
+  dependencies:
+    browserslist "^4.8.5"
+    semver "7.0.0"
 
-core-util-is@1.0.2, core-util-is@~1.0.0:
+core-util-is@1.0.2:
   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"
+custom-event@~1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
+  integrity sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=
 
 dashdash@^1.12.0:
   version "1.14.1"
@@ -2150,28 +1793,31 @@
   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"
+date-format@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/date-format/-/date-format-2.1.0.tgz#31d5b5ea211cf5fd764cd38baf9d033df7e125cf"
+  integrity sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==
 
-debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8:
+debounce@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131"
+  integrity sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==
+
+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.2.6, debug@^3.0.0, debug@^3.1.0:
+debug@3.2.6, debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.6:
   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:
+debug@^4.1.0, debug@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
   integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
@@ -2185,23 +1831,11 @@
   dependencies:
     ms "2.0.0"
 
-decamelize@^1.1.2, decamelize@^1.2.0:
+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"
@@ -2209,11 +1843,21 @@
   dependencies:
     type-detect "^4.0.0"
 
-deep-extend@^0.6.0, deep-extend@~0.6.0:
+deep-equal@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+  integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=
+
+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==
 
+deepmerge@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
+  integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
+
 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"
@@ -2221,138 +1865,60 @@
   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:
+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, 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:
+depd@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
+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=
+di@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c"
+  integrity sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=
 
-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:
+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==
 
-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"
+diff@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
+  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
 
-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=
+dom-serialize@^2.2.0:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
+  integrity sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=
   dependencies:
-    urijs "^1.16.1"
+    custom-event "~1.0.0"
+    ent "~2.2.0"
+    extend "^3.0.0"
+    void-elements "^2.0.0"
 
-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"
+dynamic-import-polyfill@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/dynamic-import-polyfill/-/dynamic-import-polyfill-0.1.1.tgz#e1f9eb1876ee242bd56572f8ed4df768e143083f"
+  integrity sha512-m953zv0w5oDagTItWm6Auhmk/pY7EiejaqiVbnzSS3HIjh1FCUeK7WzuaVtWPNs58A+/xpIE+/dVk6pKsrua8g==
 
 ecc-jsbn@~0.1.1:
   version "0.1.2"
@@ -2367,56 +1933,49 @@
   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=
+electron-to-chromium@^1.3.483:
+  version "1.3.486"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.486.tgz#90856e6c9f488079225cf5a0b4d4af6c241e0965"
+  integrity sha512-fmnACh6Jiuagm9tAfEZNe6QrwvOYAC5y0BwzoEOGCsbqriKOCaafXf3lsIvL55xa75Jmg4oboI7f5tMuoXrjNg==
 
 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:
+encodeurl@^1.0.2, 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:
+end-of-stream@^1.1.0:
   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==
+engine.io-client@~3.2.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.2.1.tgz#6f54c0475de487158a1a7c77d10178708b6add36"
+  integrity sha512-y5AbkytWeM4jQr7m/koQLc5AxpRKC1hEVUb/s1FUAWEJq5AzJJ4NLvzuKPuxtDi5Mq755WuDvZ6Iv2rXj4PTzw==
   dependencies:
     component-emitter "1.2.1"
     component-inherit "0.0.3"
-    debug "~4.1.0"
-    engine.io-parser "~2.2.0"
+    debug "~3.1.0"
+    engine.io-parser "~2.1.1"
     has-cors "1.1.0"
     indexof "0.0.1"
     parseqs "0.0.5"
     parseuri "0.0.5"
-    ws "~6.1.0"
+    ws "~3.3.1"
     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==
+engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
+  integrity sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==
   dependencies:
     after "0.8.2"
     arraybuffer.slice "~0.0.7"
@@ -2424,46 +1983,126 @@
     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==
+engine.io@~3.2.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.2.1.tgz#b60281c35484a70ee0351ea0ebff83ec8c9522a2"
+  integrity sha512-+VlKzHzMhaU+GsCIg4AoXF1UdDFjHHwMmMKqMJNDNLlUlejz58FCy4LBqB2YVJskHGYl06BatYWKP2TVdVXE5w==
   dependencies:
     accepts "~1.3.4"
-    base64id "2.0.0"
+    base64id "1.0.0"
     cookie "0.3.1"
-    debug "~4.1.0"
-    engine.io-parser "~2.2.0"
-    ws "^7.1.2"
+    debug "~3.1.0"
+    engine.io-parser "~2.1.0"
+    ws "~3.3.1"
 
-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==
+ent@~2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
+  integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0=
 
-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"
 
-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==
+es-abstract@^1.17.0-next.1, es-abstract@^1.17.5:
+  version "1.17.6"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a"
+  integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==
   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"
+    is-callable "^1.2.0"
+    is-regex "^1.1.0"
     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"
+    string.prototype.trimend "^1.0.1"
+    string.prototype.trimstart "^1.0.1"
+
+es-dev-server@^1.56.0:
+  version "1.56.0"
+  resolved "https://registry.yarnpkg.com/es-dev-server/-/es-dev-server-1.56.0.tgz#8703af87595f02fe9a1c92a07e64b7c7cc915a87"
+  integrity sha512-SL4CXdiku0hiB8zpsBLtEd7b8etIZE6IV0tIi02m0CcpTYV0rDMEvCBUYsQIN5hggJDDTBURgQjOWcT5kQv2eA==
+  dependencies:
+    "@babel/core" "^7.9.0"
+    "@babel/plugin-proposal-dynamic-import" "^7.8.3"
+    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.8.3"
+    "@babel/plugin-proposal-optional-chaining" "^7.9.0"
+    "@babel/plugin-syntax-class-properties" "^7.8.3"
+    "@babel/plugin-syntax-import-meta" "^7.8.3"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
+    "@babel/plugin-syntax-numeric-separator" "^7.8.3"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.3"
+    "@babel/plugin-transform-template-literals" "^7.8.3"
+    "@babel/preset-env" "^7.9.0"
+    "@koa/cors" "^3.1.0"
+    "@open-wc/building-utils" "^2.18.0"
+    "@rollup/plugin-node-resolve" "^7.1.1"
+    "@rollup/pluginutils" "^3.0.0"
+    "@types/babel__core" "^7.1.3"
+    "@types/browserslist" "^4.8.0"
+    "@types/browserslist-useragent" "^3.0.0"
+    "@types/caniuse-api" "^3.0.0"
+    "@types/command-line-args" "^5.0.0"
+    "@types/command-line-usage" "^5.0.1"
+    "@types/debounce" "^1.2.0"
+    "@types/koa" "^2.0.48"
+    "@types/koa-compress" "^2.0.9"
+    "@types/koa-etag" "^3.0.0"
+    "@types/koa-static" "^4.0.1"
+    "@types/koa__cors" "^3.0.1"
+    "@types/lru-cache" "^5.1.0"
+    "@types/minimatch" "^3.0.3"
+    "@types/path-is-inside" "^1.0.0"
+    "@types/whatwg-url" "^6.4.0"
+    browserslist "^4.9.1"
+    browserslist-useragent "^3.0.2"
+    builtin-modules "^3.1.0"
+    camelcase "^5.3.1"
+    caniuse-api "^3.0.0"
+    caniuse-lite "^1.0.30001033"
+    chokidar "^3.0.0"
+    command-line-args "^5.0.2"
+    command-line-usage "^6.1.0"
+    debounce "^1.2.0"
+    deepmerge "^4.2.2"
+    es-module-lexer "^0.3.13"
+    get-stream "^5.1.0"
+    is-stream "^2.0.0"
+    isbinaryfile "^4.0.2"
+    koa "^2.7.0"
+    koa-compress "^3.0.0"
+    koa-etag "^3.0.0"
+    koa-static "^5.0.0"
+    lru-cache "^5.1.1"
+    mime-types "^2.1.27"
+    minimatch "^3.0.4"
+    open "^7.0.3"
+    parse5 "^5.1.1"
+    path-is-inside "^1.0.2"
+    polyfills-loader "^1.6.1"
+    portfinder "^1.0.21"
+    rollup "^2.7.2"
+    strip-ansi "^5.2.0"
+    systemjs "^6.3.1"
+    tslib "^1.11.1"
+    useragent "^2.3.0"
+    whatwg-url "^7.0.0"
+
+es-module-lexer@^0.3.13:
+  version "0.3.24"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.3.24.tgz#e6b2900758e9e210d23aec2092efc13ca235adea"
+  integrity sha512-jm/i7KdJtaMDle921xIsA/MQQOGuZ6goYxhlV+k+gQNI7FtP4N6jknrmJvj++3ODpiyFGwQ4PIstJfHJQJNc+g==
+
+es-module-shims@^0.4.6:
+  version "0.4.7"
+  resolved "https://registry.yarnpkg.com/es-module-shims/-/es-module-shims-0.4.7.tgz#1419b65bbd38dfe91ab8ea5d7b4b454561e44641"
+  integrity sha512-0LTiSQoPWwdcaTVIQXhGlaDwTneD0g9/tnH1PNs3zHFFH+xoCeJclDM3rQeqF9nurXPfMKm3l9+kfPRa5VpbKg==
 
 es-to-primitive@^1.2.1:
   version "1.2.1"
@@ -2474,52 +2113,37 @@
     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==
+escalade@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.0.1.tgz#52568a77443f6927cd0ab9c73129137533c965ed"
+  integrity sha512-DR6NO3h9niOT+MZs7bjxlj2a1k+POu5RN8CLTPX2+i78bRi9eLe7+0zXgUHMnGXWybYcL61E9hGhPKqedy8tQA==
 
 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:
+escape-string-regexp@1.0.5, 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==
 
+estree-walker@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
+  integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
+
 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:
+etag@^1.3.0:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
   integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
@@ -2529,130 +2153,11 @@
   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"
@@ -2664,59 +2169,23 @@
   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==
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
 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=
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
   dependencies:
-    pend "~1.2.0"
+    to-regex-range "^5.0.1"
 
-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:
+finalhandler@1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
   integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
@@ -2729,13 +2198,6 @@
     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"
@@ -2750,29 +2212,6 @@
   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"
@@ -2780,6 +2219,11 @@
   dependencies:
     is-buffer "~2.0.3"
 
+flatted@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138"
+  integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
+
 follow-redirects@^1.0.0:
   version "1.9.0"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f"
@@ -2787,28 +2231,11 @@
   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"
@@ -2818,52 +2245,35 @@
     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:
+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-extra@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9"
+  integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==
+  dependencies:
+    graceful-fs "^4.1.2"
+    jsonfile "^4.0.0"
+    universalify "^0.1.0"
 
 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@~2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
+  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
+
+fsevents@~2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805"
+  integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -2884,20 +2294,12 @@
   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=
+get-stream@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9"
+  integrity sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==
+  dependencies:
+    pump "^3.0.0"
 
 getpass@^0.1.1:
   version "0.1.7"
@@ -2906,54 +2308,12 @@
   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=
+glob-parent@~5.1.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
+  integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==
   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"
+    is-glob "^4.0.1"
 
 glob@7.1.3:
   version "7.1.3"
@@ -2967,18 +2327,7 @@
     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:
+glob@^7.1.1, glob@^7.1.3:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -2990,118 +2339,27 @@
     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:
+graceful-fs@^4.1.2, graceful-fs@^4.1.6:
   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:
+har-validator@~5.1.3:
   version "5.1.3"
   resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
   integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
@@ -3109,13 +2367,6 @@
     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"
@@ -3123,62 +2374,26 @@
   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-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-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"
@@ -3186,55 +2401,36 @@
   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:
+he@1.2.0, he@^1.2.0:
   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==
+  version "2.8.8"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
+  integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
 
-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=
+html-minifier@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-4.0.0.tgz#cca9aad8bce1175e02e17a8c33e46d8988889f56"
+  integrity sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==
   dependencies:
-    inherits "^2.0.1"
-    obuf "^1.0.0"
-    readable-stream "^2.0.1"
-    wbuf "^1.1.0"
+    camel-case "^3.0.0"
+    clean-css "^4.2.1"
+    commander "^2.19.0"
+    he "^1.2.0"
+    param-case "^2.1.1"
+    relateurl "^0.2.7"
+    uglify-js "^3.5.1"
 
-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==
+http-assert@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.4.1.tgz#c5f725d677aa7e873ef736199b89686cceb37878"
+  integrity sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==
   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=
+    deep-equal "~1.0.1"
+    http-errors "~1.7.2"
 
 http-errors@1.7.2:
   version "1.7.2"
@@ -3247,6 +2443,17 @@
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
+http-errors@^1.6.3:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507"
+  integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    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"
@@ -3268,17 +2475,7 @@
     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:
+http-proxy@^1.13.0:
   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==
@@ -3296,22 +2493,6 @@
     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"
@@ -3319,33 +2500,6 @@
   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"
@@ -3359,7 +2513,7 @@
     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:
+inherits@2, inherits@2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -3369,152 +2523,55 @@
   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==
+intersection-observer@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.7.0.tgz#ee16bee978db53516ead2f0a8154b09b400bbdc9"
+  integrity sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg==
 
-invariant@^2.2.2:
+invariant@^2.2.2, invariant@^2.2.4:
   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-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^2.0.0"
 
 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-callable@^1.1.4, is-callable@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb"
+  integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==
 
 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-docker@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.0.0.tgz#2cb0df0e75e2d064fe1864c37cdeacb7b2dcf25b"
+  integrity sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==
 
-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@^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"
@@ -3525,102 +2582,34 @@
   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=
+is-glob@^4.0.1, 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 "^1.0.0"
+    is-extglob "^2.1.1"
 
-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:
+is-module@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
-  integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
+  resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+  integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=
 
-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@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
 
-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:
+is-regex@^1.1.0:
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
-  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
+  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.0.tgz#ece38e389e490df0dc21caea2bd596f987f767ff"
+  integrity sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw==
+  dependencies:
+    has-symbols "^1.0.1"
+
+is-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3"
+  integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==
 
 is-symbol@^1.0.2:
   version "1.0.3"
@@ -3634,68 +2623,68 @@
   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==
+is-wsl@^2.1.1:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
+  integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
+  dependencies:
+    is-docker "^2.0.0"
 
 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.0:
+  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"
+
+isbinaryfile@^4.0.2:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.6.tgz#edcb62b224e2b4710830b67498c8e4e5a4d2610b"
+  integrity sha512-ORrEy+SNVqUhrCaal4hA4fBzhggQQ+BaLntyPOdoEiwlKZW9BZiJXjg3RMiruE4tPEI3pyVPpySHQF/dKWperg==
+
 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=
 
+istanbul-lib-coverage@^2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz#675f0ab69503fad4b1d849f736baaca803344f49"
+  integrity sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==
+
+istanbul-lib-instrument@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz#a5f63d91f0bbc0c3e479ef4c5de027335ec6d630"
+  integrity sha512-5nnIN4vo5xQZHdXno/YDXJ0G+I3dAm4XgzfSVTPLQpj/zAV2dV6Juy0yaf10/zrJOJeHoN3fraFe+XRq2bFVZA==
+  dependencies:
+    "@babel/generator" "^7.4.0"
+    "@babel/parser" "^7.4.3"
+    "@babel/template" "^7.4.0"
+    "@babel/traverse" "^7.4.3"
+    "@babel/types" "^7.4.0"
+    istanbul-lib-coverage "^2.0.5"
+    semver "^6.0.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=
-
 js-yaml@3.13.1:
   version "3.13.1"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
@@ -3709,11 +2698,6 @@
   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"
@@ -3724,6 +2708,11 @@
   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"
@@ -3734,32 +2723,24 @@
   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==
+json5@^2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43"
+  integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==
   dependencies:
-    minimist "^1.2.0"
+    minimist "^1.2.5"
 
-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==
+jsonfile@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
+  integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=
+  optionalDependencies:
+    graceful-fs "^4.1.6"
 
 jsprim@^1.2.2:
   version "1.4.1"
@@ -3771,75 +2752,184 @@
     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"
+just-extend@^4.0.2:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.0.tgz#7278a4027d889601640ee0ce0e5a00b992467da4"
+  integrity sha512-ApcjaOdVTJ7y4r08xI5wIqpvwS48Q0PBG4DJROcEkH1f8MdAiNFyFxz3xoL0LWAVwjrwPYZdVHHxhRHcx/uGLA==
 
-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:
+karma-chrome-launcher@^3.1.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
-  integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=
+  resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.0.tgz#805a586799a4d05f4e54f72a204979f3f3066738"
+  integrity sha512-3dPs/n7vgz1rxxtynpzZTvb9y/GIaW8xjAwcIGttLbycqoFtI7yo1NGnQi6oFTherRE+GIhCAHZC4vEqWGhNvg==
   dependencies:
-    package-json "^4.0.0"
+    which "^1.2.1"
 
-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==
+karma-mocha-reporter@^2.2.5:
+  version "2.2.5"
+  resolved "https://registry.yarnpkg.com/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz#15120095e8ed819186e47a0b012f3cd741895560"
+  integrity sha1-FRIAlejtgZGG5HoLAS8810GJVWA=
   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"
+    chalk "^2.1.0"
+    log-symbols "^2.1.0"
+    strip-ansi "^4.0.0"
 
-lazystream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
-  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
+karma-mocha@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/karma-mocha/-/karma-mocha-2.0.1.tgz#4b0254a18dfee71bdbe6188d9a6861bf86b0cd7d"
+  integrity sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ==
   dependencies:
-    readable-stream "^2.0.5"
+    minimist "^1.2.3"
 
-load-json-file@^1.0.0:
+karma@^4.4.1:
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-4.4.1.tgz#6d9aaab037a31136dc074002620ee11e8c2e32ab"
+  integrity sha512-L5SIaXEYqzrh6b1wqYC42tNsFMx2PWuxky84pK9coK09MvmL7mxii3G3bZBh/0rvD27lqDd0le9jyhzvwif73A==
+  dependencies:
+    bluebird "^3.3.0"
+    body-parser "^1.16.1"
+    braces "^3.0.2"
+    chokidar "^3.0.0"
+    colors "^1.1.0"
+    connect "^3.6.0"
+    di "^0.0.1"
+    dom-serialize "^2.2.0"
+    flatted "^2.0.0"
+    glob "^7.1.1"
+    graceful-fs "^4.1.2"
+    http-proxy "^1.13.0"
+    isbinaryfile "^3.0.0"
+    lodash "^4.17.14"
+    log4js "^4.0.0"
+    mime "^2.3.1"
+    minimatch "^3.0.2"
+    optimist "^0.6.1"
+    qjobs "^1.1.4"
+    range-parser "^1.2.0"
+    rimraf "^2.6.0"
+    safe-buffer "^5.0.1"
+    socket.io "2.1.1"
+    source-map "^0.6.1"
+    tmp "0.0.33"
+    useragent "2.3.0"
+
+keygrip@~1.1.0:
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
-  integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=
+  resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
+  integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
+  dependencies:
+    tsscmp "1.0.6"
+
+koa-compose@^3.0.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-3.2.1.tgz#a85ccb40b7d986d8e5a345b3a1ace8eabcf54de7"
+  integrity sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=
+  dependencies:
+    any-promise "^1.1.0"
+
+koa-compose@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
+  integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==
+
+koa-compress@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compress/-/koa-compress-3.1.0.tgz#00fb0af695dc4661c6de261a18da669626ea3ca1"
+  integrity sha512-0m24/yS/GbhWI+g9FqtvStY+yJwTObwoxOvPok6itVjRen7PBWkjsJ8pre76m+99YybXLKhOJ62mJ268qyBFMQ==
+  dependencies:
+    bytes "^3.0.0"
+    compressible "^2.0.0"
+    koa-is-json "^1.0.0"
+    statuses "^1.0.0"
+
+koa-convert@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-1.2.0.tgz#da40875df49de0539098d1700b50820cebcd21d0"
+  integrity sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=
+  dependencies:
+    co "^4.6.0"
+    koa-compose "^3.0.0"
+
+koa-etag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-3.0.0.tgz#9ef7382ddd5a82ab0deb153415c915836f771d3f"
+  integrity sha1-nvc4Ld1agqsN6xU0FckVg293HT8=
+  dependencies:
+    etag "^1.3.0"
+    mz "^2.1.0"
+
+koa-is-json@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/koa-is-json/-/koa-is-json-1.0.0.tgz#273c07edcdcb8df6a2c1ab7d59ee76491451ec14"
+  integrity sha1-JzwH7c3Ljfaiwat9We52SRRR7BQ=
+
+koa-send@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.0.tgz#5e8441e07ef55737734d7ced25b842e50646e7eb"
+  integrity sha512-90ZotV7t0p3uN9sRwW2D484rAaKIsD8tAVtypw/aBU+ryfV+fR2xrcAwhI8Wl6WRkojLUs/cB9SBSCuIb+IanQ==
+  dependencies:
+    debug "^3.1.0"
+    http-errors "^1.6.3"
+    mz "^2.7.0"
+    resolve-path "^1.4.0"
+
+koa-static@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/koa-static/-/koa-static-5.0.0.tgz#5e92fc96b537ad5219f425319c95b64772776943"
+  integrity sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==
+  dependencies:
+    debug "^3.1.0"
+    koa-send "^5.0.0"
+
+koa@^2.7.0:
+  version "2.13.0"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.0.tgz#25217e05efd3358a7e5ddec00f0a380c9b71b501"
+  integrity sha512-i/XJVOfPw7npbMv67+bOeXr3gPqOAw6uh5wFyNs3QvJ47tUx3M3V9rIE0//WytY42MKz4l/MXKyGkQ2LQTfLUQ==
+  dependencies:
+    accepts "^1.3.5"
+    cache-content-type "^1.0.0"
+    content-disposition "~0.5.2"
+    content-type "^1.0.4"
+    cookies "~0.8.0"
+    debug "~3.1.0"
+    delegates "^1.0.0"
+    depd "^1.1.2"
+    destroy "^1.0.4"
+    encodeurl "^1.0.2"
+    escape-html "^1.0.3"
+    fresh "~0.5.2"
+    http-assert "^1.3.0"
+    http-errors "^1.6.3"
+    is-generator-function "^1.0.7"
+    koa-compose "^4.1.0"
+    koa-convert "^1.2.0"
+    on-finished "^2.3.0"
+    only "~0.0.2"
+    parseurl "^1.3.2"
+    statuses "^1.5.0"
+    type-is "^1.6.16"
+    vary "^1.1.2"
+
+leven@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
+  integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
+
+levenary@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77"
+  integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ==
+  dependencies:
+    leven "^3.1.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 "^2.2.0"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-    strip-bom "^2.0.0"
+    parse-json "^4.0.0"
+    pify "^3.0.0"
+    strip-bom "^3.0.0"
 
 locate-path@^3.0.0:
   version "3.0.0"
@@ -3849,175 +2939,60 @@
     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.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.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.memoize@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
+  integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
 
 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:
+lodash.uniq@^4.5.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"
+  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
+  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
-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:
+lodash@^4.17.13, 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==
 
-log-symbols@2.2.0:
+log-symbols@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4"
+  integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==
+  dependencies:
+    chalk "^2.4.2"
+
+log-symbols@^2.1.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==
+log4js@^4.0.0:
+  version "4.5.1"
+  resolved "https://registry.yarnpkg.com/log4js/-/log4js-4.5.1.tgz#e543625e97d9e6f3e6e7c9fc196dd6ab2cae30b5"
+  integrity sha512-EEEgFcE9bLgaYUKuozyFfytQM2wDHtXn4tAN41pkaxpNjAykv11GVdeI4tHtmPWW4Xrgh9R/2d7XYghDVjbKKw==
   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=
+    date-format "^2.0.0"
+    debug "^4.1.1"
+    flatted "^2.0.0"
+    rfdc "^1.1.4"
+    streamroller "^1.0.6"
 
 loose-envify@^1.0.0:
   version "1.4.0"
@@ -4026,25 +3001,12 @@
   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:
+lru-cache@4.1.x:
   version "4.1.5"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
   integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
@@ -4052,235 +3014,79 @@
     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==
+lru-cache@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
+  integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
   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"
+    yallist "^3.0.2"
 
 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":
+mime-db@1.43.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:
+mime-db@1.44.0, "mime-db@>= 1.43.0 < 2":
+  version "1.44.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92"
+  integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==
+
+mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.19:
+  version "2.1.27"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f"
+  integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==
+  dependencies:
+    mime-db "1.44.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==
 
-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:
+minimatch@3.0.4, minimatch@^3.0.2, 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@^1.2.3, minimist@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
+  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
 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==
+mkdirp@0.5.5, mkdirp@^0.5.1:
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
+  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
   dependencies:
-    for-in "^1.0.2"
-    is-extendable "^1.0.1"
+    minimist "^1.2.5"
 
-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==
+mocha@7.2.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.2.0.tgz#01cc227b00d875ab1eed03a75106689cfed5a604"
+  integrity sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==
   dependencies:
     ansi-colors "3.2.3"
     browser-stdout "1.3.1"
+    chokidar "3.3.0"
     debug "3.2.6"
     diff "3.5.0"
     escape-string-regexp "1.0.5"
@@ -4289,25 +3095,20 @@
     growl "1.10.5"
     he "1.2.0"
     js-yaml "3.13.1"
-    log-symbols "2.2.0"
+    log-symbols "3.0.0"
     minimatch "3.0.4"
-    mkdirp "0.5.1"
+    mkdirp "0.5.5"
     ms "2.1.1"
-    node-environment-flags "1.0.5"
+    node-environment-flags "1.0.6"
     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 "13.3.2"
+    yargs-parser "13.1.2"
     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"
@@ -4323,29 +3124,7 @@
   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:
+mz@^2.1.0, mz@^2.7.0:
   version "2.7.0"
   resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
   integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
@@ -4354,37 +3133,21 @@
     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==
+nise@^4.0.1:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-4.0.4.tgz#d73dea3e5731e6561992b8f570be9e363c4512dd"
+  integrity sha512-bTTRUNlemx6deJa+ZyoCUTRvH3liK5+N6VQZ4NIw90AgDXY6iPnsqplNFf6STcj+ePk0H/xqxnP75Lr0J0Fq3A==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+    "@sinonjs/fake-timers" "^6.0.0"
+    "@sinonjs/text-encoding" "^0.7.1"
+    just-extend "^4.0.2"
+    path-to-regexp "^1.7.0"
 
 no-case@^2.2.0:
   version "2.3.2"
@@ -4393,23 +3156,25 @@
   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==
+node-environment-flags@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088"
+  integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==
   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"
+node-fetch@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
+  integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
 
-normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+node-releases@^1.1.58:
+  version "1.1.58"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.58.tgz#8ee20eef30fa60e52755fcc0942def5a734fe935"
+  integrity sha512-NxBudgVKiRh/2aPWMgPR7bPTX0VPmGx5QBwCtdHitnqFE5/O8DeBXuIMH1nwNnw/aMo6AjOrpsHzfY3UbUJ7yg==
+
+normalize-package-data@^2.3.2:
   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==
@@ -4419,36 +3184,17 @@
     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:
+normalize-path@^3.0.0, 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:
+object-assign@^4.0.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
@@ -4458,32 +3204,16 @@
   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==
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0"
+  integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==
 
 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"
@@ -4494,16 +3224,6 @@
     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"
@@ -4512,26 +3232,6 @@
     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"
@@ -4539,29 +3239,25 @@
   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:
+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=
+only@~0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
+  integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=
 
-opn@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/opn/-/opn-3.0.3.tgz#b6d99e7399f78d65c3baaffef1fb288e9b85243a"
-  integrity sha1-ttmec5n3jWXDuq/+8fsojpuFJDo=
+open@^7.0.3:
+  version "7.0.4"
+  resolved "https://registry.yarnpkg.com/open/-/open-7.0.4.tgz#c28a9d315e5c98340bf979fdcb2e58664aa10d83"
+  integrity sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==
   dependencies:
-    object-assign "^4.0.1"
+    is-docker "^2.0.0"
+    is-wsl "^2.1.1"
 
 optimist@^0.6.1:
   version "0.6.1"
@@ -4571,37 +3267,11 @@
     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:
+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.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"
@@ -4621,49 +3291,25 @@
   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:
+param-case@^2.1.1:
   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:
+parse-json@^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==
+  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"
+
+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"
@@ -4679,395 +3325,123 @@
   dependencies:
     better-assert "~1.0.0"
 
-parseurl@~1.3.3:
+parseurl@^1.3.2, 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:
+path-is-absolute@1.0.1, 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:
+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:
+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=
+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:
-    graceful-fs "^4.1.2"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
+    pify "^3.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=
+picomatch@^2.0.4, picomatch@^2.0.7, picomatch@^2.2.2:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
+  integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
 
 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=
+polyfills-loader@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/polyfills-loader/-/polyfills-loader-1.6.1.tgz#134ab74b9a6160efb4d72066a5150bfb2228fad3"
+  integrity sha512-GK3jZGLy9nApfRYfHrrO4RYkBkpjiXUVWVdp169g4Y8HV+ZazrGQX46tNpbwP0dtrgHgADyJvZYPfdFuooHy5Q==
   dependencies:
-    pinkie "^2.0.0"
+    "@babel/core" "^7.9.0"
+    "@open-wc/building-utils" "^2.18.0"
+    "@webcomponents/webcomponentsjs" "^2.4.0"
+    abortcontroller-polyfill "^1.4.0"
+    core-js-bundle "^3.6.0"
+    deepmerge "^4.2.2"
+    dynamic-import-polyfill "^0.1.1"
+    es-module-shims "^0.4.6"
+    html-minifier "^4.0.0"
+    intersection-observer "^0.7.0"
+    parse5 "^5.1.1"
+    regenerator-runtime "^0.13.3"
+    resize-observer-polyfill "^1.5.1"
+    systemjs "^6.3.1"
+    terser "^4.6.7"
+    whatwg-fetch "^3.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=
+portfinder@^1.0.21:
+  version "1.0.26"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.26.tgz#475658d56ca30bed72ac7f1378ed350bd1b64e70"
+  integrity sha512-Xi7mKxJHHMI3rIUrnm/jjUgwhbYMkp/XKEcZX3aG4BrumLpq3nmoQMX+ClYnDZnZ/New7IatC1no5RX0zo1vXQ==
   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"
+    async "^2.6.2"
+    debug "^3.1.1"
     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==
+psl@^1.1.28:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
+  integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
 
-punycode@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
-  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
+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"
 
-punycode@^2.1.0:
+punycode@^2.1.0, punycode@^2.1.1:
   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=
+qjobs@^1.1.4:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071"
+  integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==
 
 qs@6.7.0:
   version "6.7.0"
@@ -5079,16 +3453,7 @@
   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:
+range-parser@^1.2.0:
   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==
@@ -5103,202 +3468,99 @@
     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==
+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:
-    deep-extend "^0.6.0"
-    ini "~1.3.0"
-    minimist "^1.2.0"
-    strip-json-comments "~2.0.1"
+    find-up "^3.0.0"
+    read-pkg "^3.0.0"
 
-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=
+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:
-    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"
+    load-json-file "^4.0.0"
     normalize-package-data "^2.3.2"
-    path-type "^1.0.0"
+    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=
+readdirp@~3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839"
+  integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==
   dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
+    picomatch "^2.0.4"
 
-"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=
+readdirp@~3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17"
+  integrity sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==
   dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
+    picomatch "^2.0.7"
 
-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"
+reduce-flatten@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
+  integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
 
-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==
+regenerate-unicode-properties@^8.2.0:
+  version "8.2.0"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
+  integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==
   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==
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.1.tgz#cad92ad8e6b591773485fbe05a485caf4f457e6f"
+  integrity sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A==
 
-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-runtime@^0.13.3, regenerator-runtime@^0.13.4:
+  version "0.13.5"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697"
+  integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==
 
-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==
+regenerator-transform@^0.14.2:
+  version "0.14.5"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4"
+  integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==
   dependencies:
-    private "^0.1.6"
+    "@babel/runtime" "^7.8.4"
 
-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==
+regexpu-core@^4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.0.tgz#fcbf458c50431b0bb7b45d6967b8192d91f3d938"
+  integrity sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ==
   dependencies:
     regenerate "^1.4.0"
-    regenerate-unicode-properties "^8.1.0"
-    regjsgen "^0.5.0"
-    regjsparser "^0.6.0"
+    regenerate-unicode-properties "^8.2.0"
+    regjsgen "^0.5.1"
+    regjsparser "^0.6.4"
     unicode-match-property-ecmascript "^1.0.4"
-    unicode-match-property-value-ecmascript "^1.1.0"
+    unicode-match-property-value-ecmascript "^1.2.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"
+regjsgen@^0.5.1:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
+  integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
 
-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==
+regjsparser@^0.6.4:
+  version "0.6.4"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.4.tgz#a769f8684308401a66e9b529d2436ff4d0666272"
+  integrity sha512-64O87/dPDgfk8/RQqC4gkZoGyyWFIEUTTh80CU6CWuK5vkCGyekIx+oKcEIYtP/RAxSQltCZHCNu/mdd7fqlJw==
   dependencies:
     jsesc "~0.5.0"
 
-relateurl@0.2.x:
+relateurl@^0.2.7:
   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==
+request@^2.88.0:
+  version "2.88.2"
+  resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
+  integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
   dependencies:
     aws-sign2 "~0.7.0"
     aws4 "^1.8.0"
@@ -5307,7 +3569,7 @@
     extend "~3.0.2"
     forever-agent "~0.6.1"
     form-data "~2.3.2"
-    har-validator "~5.1.0"
+    har-validator "~5.1.3"
     http-signature "~1.2.0"
     is-typedarray "~1.0.0"
     isstream "~0.1.2"
@@ -5317,7 +3579,7 @@
     performance-now "^2.1.0"
     qs "~6.5.2"
     safe-buffer "^5.1.2"
-    tough-cookie "~2.4.3"
+    tough-cookie "~2.5.0"
     tunnel-agent "^0.6.0"
     uuid "^3.3.2"
 
@@ -5331,228 +3593,102 @@
   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=
+resize-observer-polyfill@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
+  integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
+
+resolve-path@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
+  integrity sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc=
   dependencies:
-    expand-tilde "^2.0.0"
-    global-modules "^1.0.0"
+    http-errors "~1.6.2"
+    path-is-absolute "1.0.1"
 
-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==
+resolve@^1.10.0, resolve@^1.11.1, resolve@^1.14.2, resolve@^1.3.2:
+  version "1.17.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444"
+  integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==
   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==
+rfdc@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.1.4.tgz#ba72cc1367a0ccd9cf81a870b3b58bd3ad07f8c2"
+  integrity sha512-5C9HXdzK8EAqN7JDif30jqsBzavB7wLpaubisuQIGHWf2gUXSpzy6ArX/+Da8RjFpagWsCn+pIgxTMAmKw9Zug==
 
-rimraf@^2.5.4:
+rimraf@^2.6.0:
   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==
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
   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"
+rollup@^2.7.2:
+  version "2.18.2"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.18.2.tgz#886ac6e4549e493df106c3e2580c89aeb997be25"
+  integrity sha512-+mzyZhL9ZyLB3eHBISxRNTep9Z2qCuwXzAYkUbFyz7yNKaKH03MFKeiGOS1nv2uvPgDb4ASKv+FiS5mC4h5IFQ==
+  optionalDependencies:
+    fsevents "~2.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.1.2, safe-buffer@~5.2.0:
+safe-buffer@^5.0.1:
   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"
+safe-buffer@^5.1.2:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
 "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:
+"semver@2 || 3 || 4 || 5", 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"
+semver@7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
+  integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
 
-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"
+semver@^6.0.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
-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=
+semver@^7.3.2:
+  version "7.3.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938"
+  integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==
 
 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"
@@ -5563,192 +3699,110 @@
   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:
+setprototypeof@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=
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
+shady-css-scoped-element@^0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/shady-css-scoped-element/-/shady-css-scoped-element-0.0.2.tgz#c538fcfe2317e979cd02dfec533898b95b4ea8fe"
+  integrity sha512-Dqfl70x6JiwYDujd33ZTbtCK0t52E7+H2swdWQNSTzfsolSa6LJHnTpN4T9OpJJEq4bxuzHRLFO9RBcy/UfrMQ==
+
+sinon@^9.0.2:
+  version "9.0.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.0.2.tgz#b9017e24633f4b1c98dfb6e784a5f0509f5fd85d"
+  integrity sha512-0uF8Q/QHkizNUmbK3LRFqx5cpTttEVXudywY9Uwzy8bTfZUhljZ7ARzSxnRHWYWtVTeh4Cw+tTb3iU21FQVO9A==
   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"
+    "@sinonjs/commons" "^1.7.2"
+    "@sinonjs/fake-timers" "^6.0.1"
+    "@sinonjs/formatio" "^5.0.1"
+    "@sinonjs/samsam" "^5.0.3"
+    diff "^4.0.2"
+    nise "^4.0.1"
+    supports-color "^7.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==
+socket.io-client@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.1.1.tgz#dcb38103436ab4578ddb026638ae2f21b623671f"
+  integrity sha512-jxnFyhAuFxYfjqIgduQlhzqTcOEQSn+OHKVfAxWaNWa7ecP7xSNk2Dx/3UEsDcY7NcFafxvNvKPmmO7HTwTxGQ==
   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"
+    debug "~3.1.0"
+    engine.io-client "~3.2.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"
+    socket.io-parser "~3.2.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==
+socket.io-parser@~3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077"
+  integrity sha512-FYiBx7rc/KORMJlgsXysflWx/RIvtqZbyGLlHZvjfmPTPeuD/I8MaW7cfFrj5tRltICJdgwflhfZ3NVVbVLFQA==
   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==
+socket.io@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980"
+  integrity sha512-rORqq9c+7W0DAK3cleWNSyfv/qKXV99hV4tZe+gGLfBECw3XEhBy7x85F3wypA9688LKjtwO9pX9L33/xQI8yA==
   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"
+    debug "~3.1.0"
+    engine.io "~3.2.0"
     has-binary2 "~1.0.2"
     socket.io-adapter "~1.1.0"
-    socket.io-client "2.3.0"
-    socket.io-parser "~3.4.0"
+    socket.io-client "2.1.1"
+    socket.io-parser "~3.2.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==
+source-map-support@^0.5.19, source-map-support@~0.5.12:
+  version "0.5.19"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
+  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
   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"
+    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:
+source-map@^0.5.0:
   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:
+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==
 
 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==
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
+  integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==
   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==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
+  integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
 
 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==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
+  integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
   dependencies:
     spdx-exceptions "^2.1.0"
     spdx-license-ids "^3.0.0"
@@ -5758,38 +3812,6 @@
   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"
@@ -5810,60 +3832,23 @@
     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:
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.0.0, statuses@^1.5.0, 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=
+streamroller@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-1.0.6.tgz#8167d8496ed9f19f05ee4b158d9611321b8cacd9"
+  integrity sha512-3QC47Mhv3/aZNFpDDVO44qQb9gwB9QggMEE0sQmkTAwBVYdBRWISdsywlkfm5II1Q5y/pmrHflti/IgmIzdDBg==
   dependencies:
-    emitter-component "^1.1.1"
+    async "^2.6.2"
+    date-format "^2.0.0"
+    debug "^3.2.6"
+    fs-extra "^7.0.1"
+    lodash "^4.17.14"
 
-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:
+"string-width@^1.0.2 || 2":
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
   integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
@@ -5880,47 +3865,21 @@
     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==
+string.prototype.trimend@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913"
+  integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==
   dependencies:
     define-properties "^1.1.3"
-    function-bind "^1.1.1"
+    es-abstract "^1.17.5"
 
-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==
+string.prototype.trimstart@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54"
+  integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==
   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"
+    es-abstract "^1.17.5"
 
 strip-ansi@^4.0.0:
   version "4.0.0"
@@ -5936,55 +3895,16 @@
   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@^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-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:
+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"
@@ -5992,11 +3912,6 @@
   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"
@@ -6004,96 +3919,46 @@
   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==
+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:
-    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"
+    has-flag "^4.0.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"
+systemjs@^6.3.1:
+  version "6.3.3"
+  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.3.3.tgz#c0f2bec5cc72d0b36a8b971b1fa32bfc828b50d4"
+  integrity sha512-djQ6mZ4/cWKnVnhAWvr/4+5r7QHnC7WiA8sS9VuYRdEv3wYZYTIIQv8zPT79PdDSUwfX3bgvu5mZ8eTyLm2YQA==
 
-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==
+table-layout@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.1.tgz#8411181ee951278ad0638aea2f779a9ce42894f9"
+  integrity sha512-dEquqYNJiGwY7iPfZ3wbXDI944iqanTSchrACLL2nOB+1r+h1Nzu2eH+DuPPvWvm5Ry7iAPeFlgEtP5bIp5U7Q==
   dependencies:
-    array-back "^2.0.0"
+    array-back "^4.0.1"
     deep-extend "~0.6.0"
-    lodash.padend "^4.6.1"
-    typical "^2.6.1"
-    wordwrapjs "^3.0.0"
+    typical "^5.2.0"
+    wordwrapjs "^4.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==
+terser@^4.6.7:
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
+  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
   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"
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
 
-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==
+test-exclude@^5.2.3:
+  version "5.2.3"
+  resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.2.3.tgz#c3d3e1e311eb7ee405e092dac10aefd09091eac0"
+  integrity sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g==
   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==
+    glob "^7.1.3"
+    minimatch "^3.0.4"
+    read-pkg-up "^4.0.0"
+    require-main-filename "^2.0.0"
 
 thenify-all@^1.0.0:
   version "1.6.0"
@@ -6103,108 +3968,48 @@
     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=
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
+  integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
   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=
+tmp@0.0.33, tmp@0.0.x:
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
+  integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
   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"
+    os-tmpdir "~1.0.2"
 
 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=
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
   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"
+    is-number "^7.0.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==
+tough-cookie@~2.5.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
+  integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
   dependencies:
-    psl "^1.1.24"
-    punycode "^1.4.1"
+    psl "^1.1.28"
+    punycode "^2.1.1"
 
 tr46@^1.0.1:
   version "1.0.1"
@@ -6213,20 +4018,15 @@
   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=
+tslib@^1.11.1:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
 
-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==
+tsscmp@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
 
 tunnel-agent@^0.6.0:
   version "0.6.0"
@@ -6240,22 +4040,12 @@
   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:
+type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8:
   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:
+type-is@^1.6.16, type-is@~1.6.17:
   version "1.6.18"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
   integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
@@ -6263,43 +4053,25 @@
     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==
+typical@^5.0.0, typical@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
+  integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 
-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"
+uglify-js@^3.5.1:
+  version "3.10.0"
+  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.10.0.tgz#397a7e6e31ce820bfd1cb55b804ee140c587a9e7"
+  integrity sha512-Esj5HG5WAyrLIdYU74Z3JdG2PxdIusvj6IWHMtlyESxc7kcDz7zYlYjpnSokn1UbpV0d/QX9fan7gkCNd/9BQA==
 
-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=
+ultron@~1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c"
+  integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==
 
 unicode-canonical-property-names-ecmascript@^1.0.4:
   version "1.0.4"
@@ -6314,82 +4086,26 @@
     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-match-property-value-ecmascript@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531"
+  integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==
 
 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==
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
+  integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
 
-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"
+universalify@^0.1.0:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
+  integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
 
 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"
@@ -6402,58 +4118,28 @@
   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=
+useragent@2.3.0, useragent@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/useragent/-/useragent-2.3.0.tgz#217f943ad540cb2128658ab23fc960f6a88c9972"
+  integrity sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==
   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"
+    lru-cache "4.1.x"
+    tmp "0.0.x"
 
 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:
+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=
+valid-url@^1.0.9:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200"
+  integrity sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA=
 
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
@@ -6463,12 +4149,7 @@
     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:
+vary@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
   integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
@@ -6482,159 +4163,25 @@
     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"
+void-elements@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
+  integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
 
 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==
+whatwg-fetch@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.1.0.tgz#49d630cdfa308dba7f2819d49d09364f540dbcc6"
+  integrity sha512-pgmbsVWKpH9GxLXZmtdowDIqtb/rvPyjjQv3z9wLcmgWKFHilKnZD3ldgrOlwJoPGOUluQsRPWd52yVkPfmI1A==
+
+whatwg-url@^7.0.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06"
+  integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==
   dependencies:
     lodash.sortby "^4.7.0"
     tr46 "^1.0.1"
@@ -6645,7 +4192,7 @@
   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:
+which@1.3.1, which@^1.2.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
   integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
@@ -6659,48 +4206,18 @@
   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==
+wordwrapjs@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.0.tgz#9aa9394155993476e831ba8e59fb5795ebde6800"
+  integrity sha512-Svqw723a3R34KvsMgpjFBYCgNOSdcW3mQFK4wIfhGQhtaFVOJmdYoXgi63ne3dTlWgatVcUc7t4HtQ/+bUVIzQ==
   dependencies:
-    reduce-flatten "^1.0.1"
-    typical "^2.6.1"
+    reduce-flatten "^2.0.0"
+    typical "^5.0.0"
 
 wrap-ansi@^5.1.0:
   version "5.1.0"
@@ -6716,52 +4233,20 @@
   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==
+ws@~3.3.1:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2"
+  integrity sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==
   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==
+    safe-buffer "~5.1.0"
+    ultron "~1.1.0"
 
 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"
@@ -6772,10 +4257,15 @@
   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==
+yallist@^3.0.2:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
+  integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
+
+yargs-parser@13.1.2, yargs-parser@^13.1.2:
+  version "13.1.2"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
+  integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==
   dependencies:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
@@ -6789,10 +4279,10 @@
     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==
+yargs@13.3.2, yargs@^13.3.0:
+  version "13.3.2"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
+  integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==
   dependencies:
     cliui "^5.0.0"
     find-up "^3.0.0"
@@ -6803,26 +4293,14 @@
     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"
+    yargs-parser "^13.1.2"
 
 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"
+ylru@^1.2.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
+  integrity sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==
diff --git a/prolog/gerrit_common.pl b/prolog/gerrit_common.pl
index e2857d0..407e5d6 100644
--- a/prolog/gerrit_common.pl
+++ b/prolog/gerrit_common.pl
@@ -429,3 +429,19 @@
 commit_message_matches(Pattern) :-
   commit_message(Msg),
   regex_matches(Pattern, Msg).
+
+
+%% member/2:
+%%
+:- public member/2.
+%%
+member(X,[X|_]).
+member(X,[Y|T]) :- member(X,T).
+
+%% includes_file/1:
+%%
+:- public includes_file/1.
+%%
+includes_file(File) :-
+  files(List),
+  member(File, List).
\ No newline at end of file
diff --git a/proto/cache.proto b/proto/cache.proto
index e157608..7924cbd 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -323,3 +323,180 @@
   repeated ProjectWatchProto project_watch_proto = 2;
   string user_preferences = 3;
 }
+
+// Serialized form of com.google.gerrit.entities.Project.
+// Next ID: 11
+message ProjectProto {
+  string name = 1;
+  string description = 2;
+  map<string, string> boolean_configs = 3;
+  string submit_type = 4; // ENUM as String
+  string state = 5; // ENUM as String
+  string parent = 6;
+  string max_object_size_limit = 7;
+  string default_dashboard = 8;
+  string local_default_dashboard = 9;
+  string config_ref_state = 10;
+}
+
+// Serialized form of com.google.gerrit.common.data.GroupReference.
+// Next ID: 3
+message GroupReferenceProto {
+  string uuid = 1;
+  string name = 2;
+}
+
+// Serialized form of com.google.gerrit.common.data.PermissionRule.
+// Next ID: 6
+message PermissionRuleProto {
+  string action = 1; // ENUM as String
+  bool force = 2;
+  int32 min = 3;
+  int32 max = 4;
+  GroupReferenceProto group = 5;
+}
+
+// Serialized form of com.google.gerrit.common.data.Permission.
+// Next ID: 4
+message PermissionProto {
+  string name = 1;
+  bool exclusive_group = 2;
+  repeated PermissionRuleProto rules = 3;
+}
+
+// Serialized form of com.google.gerrit.common.data.AccessSection.
+// Next ID: 3
+message AccessSectionProto {
+  string name = 1;
+  repeated PermissionProto permissions = 2;
+}
+
+// Serialized form of com.google.gerrit.server.git.BranchOrderSection.
+// Next ID: 2
+message BranchOrderSectionProto {
+  repeated string branches_in_order = 1;
+}
+
+// Serialized form of com.google.gerrit.common.data.ContributorAgreement.
+// Next ID: 8
+message ContributorAgreementProto {
+  string name = 1;
+  string description = 2;
+  repeated PermissionRuleProto accepted = 3;
+  GroupReferenceProto auto_verify = 4;
+  string url = 5;
+  repeated string exclude_regular_expressions = 6;
+  repeated string match_regular_expressions = 7;
+}
+
+// Serialized form of com.google.gerrit.entities.Address.
+// Next ID: 3
+message AddressProto {
+  string name = 1;
+  string email = 2;
+}
+
+// Serialized form of com.google.gerrit.entities.NotifyConfig.
+// Next ID: 7
+message NotifyConfigProto {
+  string name = 1;
+  repeated string type = 2; // ENUM as String
+  string filter = 3;
+  string header = 4; // ENUM as String
+  repeated GroupReferenceProto groups = 5;
+  repeated AddressProto addresses = 6;
+}
+
+// Serialized form of com.google.gerrit.entities.LabelValue.
+// Next ID: 3
+message LabelValueProto {
+  string text = 1;
+  int32 value = 2;
+}
+
+// Serialized form of com.google.gerrit.common.data.LabelType.
+// Next ID: 19
+message LabelTypeProto {
+  string name = 1;
+  string function = 2; // ENUM as String
+  bool copy_any_score = 3;
+  bool copy_min_score = 4;
+  bool copy_max_score = 5;
+  bool copy_all_scores_on_merge_first_parent_update = 6;
+  bool copy_all_scores_on_trivial_rebase = 7;
+  bool copy_all_scores_if_no_code_change = 8;
+  bool copy_all_scores_if_no_change = 9;
+  repeated int32 copy_values = 10;
+  bool allow_post_submit = 11;
+  bool ignore_self_approval = 12;
+  int32 default_value = 13;
+  repeated LabelValueProto values = 14;
+  int32 max_negative = 15;
+  int32 max_positive = 16;
+  bool can_override = 17;
+  repeated string ref_patterns = 18;
+}
+
+// Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
+// Next ID: 4
+message ConfiguredMimeTypeProto {
+  string type = 1;
+  string pattern = 2;
+  bool is_regular_expression = 3;
+}
+
+// Serialized form of com.google.gerrit.common.data.SubscribeSection.
+// Next ID: 4
+message SubscribeSectionProto {
+  string project_name = 1;
+  repeated string multi_match_ref_specs = 2;
+  repeated string matching_ref_specs = 3;
+}
+
+// Serialized form of com.google.gerrit.entities.StoredCommentLinkInfo.
+// Next ID: 7
+message StoredCommentLinkInfoProto {
+  string name = 1;
+  string match = 2;
+  string link = 3;
+  string html = 4;
+  bool enabled = 5;
+  bool override_only = 6;
+}
+
+// Serialized form of com.google.gerrit.entities.CachedProjectConfigProto.
+// Next ID: 19
+message CachedProjectConfigProto {
+  ProjectProto project = 1;
+  repeated GroupReferenceProto group_list = 2;
+  repeated PermissionRuleProto accounts_section = 3;
+  repeated AccessSectionProto access_sections = 4;
+  BranchOrderSectionProto branch_order_section = 5;
+  repeated ContributorAgreementProto contributor_agreements = 6;
+  repeated NotifyConfigProto notify_configs = 7;
+  repeated LabelTypeProto label_sections = 8;
+  repeated ConfiguredMimeTypeProto mime_types = 9;
+  repeated SubscribeSectionProto subscribe_sections = 10;
+  repeated StoredCommentLinkInfoProto comment_links = 11;
+  bytes rules_id = 12;
+  bytes revision = 13;
+  int64 max_object_size_limit = 14;
+  bool check_received_objects = 15;
+  map<string, ExtensionPanelSectionProto> extension_panels = 16;
+  map<string, string> plugin_configs = 17;
+  map<string, string> project_level_configs = 18;
+
+  // Next ID: 2
+  message ExtensionPanelSectionProto {
+    repeated string section = 1;
+  }
+}
+
+// Serialized key for com.google.gerrit.server.project.ProjectCacheImpl.
+// Next ID: 4
+message ProjectCacheKeyProto {
+  string project = 1;
+  bytes revision = 2;
+  bytes global_config_revision = 3; // Hash of All-Projects-projects.config. This
+                                    // will only be populated for All-Projects.
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index d162714..000f4e2 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -30,8 +30,8 @@
   {@param? changeRequestsPath: ?}
   {@param? defaultChangeDetailHex: ?}
   {@param? defaultDiffDetailHex: ?}
-  {@param? preloadChangePage: ?}
-  {@param? preloadDiffPage: ?}
+  {@param? defaultDashboardHex: ?}
+  {@param? dashboardQuery: ?}
   {@param? userIsAuthenticated: ?}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
@@ -55,6 +55,14 @@
       {if $defaultDiffDetailHex}
         diffPage: '{$defaultDiffDetailHex}',
       {/if}
+      {if $defaultDashboardHex}
+        dashboardPage: '{$defaultDashboardHex}',
+      {/if}
+    {rb};
+    window.PRELOADED_QUERIES = {lb}
+      {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
+        dashboardQuery: [{for $query in $dashboardQuery}{$query},{/for}],
+      {/if}
     {rb};
     {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
     {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
@@ -85,17 +93,18 @@
     <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
   {/if}
   {if $changeRequestsPath}
-    {if $preloadChangePage and $defaultChangeDetailHex}
+    {if $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}
+    {if $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}
+      <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script"/>
     {/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}
@@ -103,6 +112,9 @@
       <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {/if}
   {/if}
+  {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
+    <link rel="preload" href="{$canonicalPath}/changes/?O={$defaultDashboardHex}&S=0{for $query in $dashboardQuery}&q={$query}{/for}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+  {/if}
 
   {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">
@@ -139,7 +151,7 @@
   // Content between webcomponents-lite and the load of the main app element
   // run before polymer-resin is installed so may have security consequences.
   // Contact your local security engineer if you have any questions, and
-  // CC them on any changes that load content before gr-app.html.
+  // CC them on any changes that load content before gr-app.js.
   //
   // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
   {if $assetsPath and $assetsBundle}
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index 7dcd441..e7fda5a 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -326,6 +326,11 @@
 GERRIT_FDS=`expr $FDS_MULTIPLIER \* $GERRIT_FDS`
 test $GERRIT_FDS -lt 1024 && GERRIT_FDS=1024
 
+CACHE_FDS=`get_config --get cache.openFiles`
+if test -n "$CACHE_FDS"; then
+  GERRIT_FDS=`expr $CACHE_FDS \+ $GERRIT_FDS`
+fi
+
 GERRIT_STARTUP_TIMEOUT=`get_config --get container.startupTimeout`
 test -z "$GERRIT_STARTUP_TIMEOUT" && GERRIT_STARTUP_TIMEOUT=90  # seconds
 
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index d797be3..4f1a3f7 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -110,6 +110,25 @@
   fi
 }
 
+# gerrit.reviewUrl causes us to create Link instead of Change-Id.
+function test_link {
+  cat << EOF > input
+bla bla
+EOF
+
+  git config gerrit.reviewUrl https://myhost/
+  ${hook} input || fail "failed hook execution"
+  git config --unset gerrit.reviewUrl
+  found=$(grep -c '^Change-Id' input || true)
+  if [[ "${found}" != "0" ]]; then
+    fail "got ${found} Change-Ids, want 0"
+  fi
+  found=$(grep -c '^Link: https://myhost/id/I' input || true)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Link footers, want 1"
+  fi
+}
+
 # Change-Id goes after existing trailers.
 function test_at_end {
   cat << EOF > input
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
new file mode 100644
index 0000000..5ea41b2
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
@@ -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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .AddToAttentionSet template will determine the contents of the email related to a
+ * user being added to the attention set.
+ */
+{template .AddToAttentionSet kind="text"}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param attentionSetUser: ?}
+  {@param reason: ?}
+  {if $fromName == $attentionSetUser}
+  {$fromName} added themselves to the attention set of this change.
+  {else}
+  {$fromName} requires the attention of {$attentionSetUser} to this change.
+  {/if}
+  {\n} The reason is: {$reason}.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
new file mode 100644
index 0000000..bac180a
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
@@ -0,0 +1,43 @@
+/**
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .AddToAttentionSetHtml}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param attentionSetUser: ?}
+  {@param reason: ?}
+  <p>
+    {if $fromName == $attentionSetUser}
+      {$fromName} added themselves to the attention set of this change.
+    {else}
+      {$fromName} requires the attention of {$attentionSetUser} to this change.
+    {/if}
+    {\n} The reason is: {$reason}.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeader.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
new file mode 100644
index 0000000..fde69f1
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -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.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .ChangeHeader kind="text"}
+  {@param attentionSet: ?}
+  {if $attentionSet}
+    Attention is currently required from:{sp}
+    {for $attentionSetUser in $attentionSet}
+      {$attentionSetUser}
+      // add commas or dot.
+      {if isLast($attentionSetUser)}.
+      {else},{sp}
+      {/if}
+    {/for}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
new file mode 100644
index 0000000..ea12455
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -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.
+*/
+
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .ChangeHeaderHtml}
+  {@param attentionSet: ?}
+  {if $attentionSet}
+    <p> Attention is currently required from:{sp}
+    {for $attentionSetUser in $attentionSet}
+      {$attentionSetUser}
+      //add commas or dot.
+      {if isLast($attentionSetUser)}.
+      {else},{sp}
+      {/if}
+    {/for} </p>
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 1eb016b..893ef6f 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -39,9 +39,6 @@
   {/if}
 
   {for $group in $commentFiles}
-    // Insert a space before the newline so that Gmail does not mistakenly link
-    // the following line with the file link. See issue 9201.
-    {$group.link}{sp}{\n}
     {$group.title}:{\n}
     {\n}
 
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 534cbdb..21fee18 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -111,7 +111,7 @@
     {for $group in $commentFiles}
       <li style="{$fileLiStyle}">
         <p>
-          <a href="{$group.link}">{$group.title}:</a>
+          {$group.title}:
         </p>
 
         <ul style="{$ulStyle}">
diff --git a/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/resources/com/google/gerrit/server/mail/HeaderHtml.soy
deleted file mode 100644
index 4710d8c..0000000
--- a/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ /dev/null
@@ -1,20 +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.
-*/
-
-{namespace com.google.gerrit.server.mail.template}
-
-{template .HeaderHtml}
-{/template}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 9de8707..e16b213 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -32,7 +32,8 @@
         {/if}
         {$reviewerName}
       {/for}{sp}
-      to <strong>review</strong> this change.
+      to <strong>review</strong> this change
+      {if $fromName != $ownerName}{sp}authored by {$ownerName}{/if}.
     {else}
       {$ownerName} has uploaded this change for <strong>review</strong>.
     {/if}
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
new file mode 100644
index 0000000..033d145
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
@@ -0,0 +1,48 @@
+/**
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .RegisterNewEmailHtml}
+  {@param email: ?}
+  <p>
+    Welcome to Gerrit Code Review at {$email.gerritHost}.
+    To add a verified email address to your user account, please
+    click on the following link
+  </p>
+  {if $email.userNameEmail}
+    <p>
+        {sp}while signed in as {$email.userNameEmail}
+    </p>
+  {/if}:
+
+  <p>
+
+    {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}
+  </p>
+  <p>
+    If you have received this mail in error, you do not need to take any
+    action to cancel the account.  The address will not be activated, and
+    you will not receive any further emails.
+  </p>
+  <p>
+    If clicking the link above does not work, copy and paste the URL in a
+    new browser window instead.
+
+    This is a send-only email address.  Replies to this message will not
+    be read or answered.
+  </p>
+{/template}
\ No newline at end of file
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
new file mode 100644
index 0000000..f116adb
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
@@ -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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .RemoveFromAttentionSet template will determine the contents of the email related to a
+ * user being added to the attention set.
+ */
+{template .RemoveFromAttentionSet kind="text"}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param attentionSetUser: ?}
+  {@param reason: ?}
+  {if $fromName == $attentionSetUser}
+  {$fromName} removed themselves from the attention set of this change.
+  {else}
+  {$fromName} doesn't require the attention of {$attentionSetUser} to this change.
+  {/if}
+  {\n} The reason is: {$reason}.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
new file mode 100644
index 0000000..55eef13
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
@@ -0,0 +1,43 @@
+/**
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .RemoveFromAttentionSetHtml}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param attentionSetUser: ?}
+  {@param reason: ?}
+  <p>
+    {if $fromName == $attentionSetUser}
+      {$fromName} removed themselves from the attention set of this change.
+    {else}
+      {$fromName} doesn't require the attention of {$attentionSetUser} to this change.
+    {/if}
+    {\n} The reason is: {$reason}.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 84eef77..41566c8 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -21,6 +21,7 @@
 cljs = text/x-clojurescript
 cmake = text/x-cmake
 cmake.in = text/x-cmake
+cml = application/json
 contributing.md = text/x-gfm
 CMakeLists.txt = text/x-cmake
 CONTRIBUTING.md = text/x-gfm
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 2901232..3a40d22 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -48,12 +48,23 @@
   exit 1
 fi
 
-# Avoid the --in-place option which only appeared in Git 2.8
-# Avoid the --if-exists option which only appeared in Git 2.15
-if ! git -c trailer.ifexists=doNothing interpret-trailers \
-      --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
-  echo "cannot insert change-id line in $1"
-  exit 1
+reviewurl="$(git config --get gerrit.reviewUrl)"
+if test -n "${reviewurl}" ; then
+  if ! git interpret-trailers --parse < "$1" | grep -q '^Link:.*/id/I[0-9a-f]\{40\}$' ; then
+    if ! git interpret-trailers \
+          --trailer "Link: ${reviewurl%/}/id/I${random}" < "$1" > "${dest}" ; then
+      echo "cannot insert link footer in $1"
+      exit 1
+    fi
+  fi
+else
+  # Avoid the --in-place option which only appeared in Git 2.8
+  # Avoid the --if-exists option which only appeared in Git 2.15
+  if ! git -c trailer.ifexists=doNothing interpret-trailers \
+        --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
+    echo "cannot insert change-id line in $1"
+    exit 1
+  fi
 fi
 
 if ! mv "${dest}" "$1" ; then
diff --git a/rules_nodejs-1.5.patch b/rules_nodejs-1.5.patch
deleted file mode 100644
index baa566b3..0000000
--- a/rules_nodejs-1.5.patch
+++ /dev/null
@@ -1,12 +0,0 @@
---- 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 5159177..545a206 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -17,6 +17,54 @@
     visibility = ["//visibility:public"],
 )
 
+JDK11_JVM_OPTS = select({
+    "@bazel_tools//src/conditions:openbsd": ["-Xbootclasspath/p:$(location @bazel_tools//tools/jdk:javac_jar)"],
+    "//conditions:default": [
+        "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
+        "--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
+        "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+        "--patch-module=java.compiler=$(location @bazel_tools//tools/jdk:java_compiler_jar)",
+        "--patch-module=jdk.compiler=$(location @bazel_tools//tools/jdk:jdk_compiler_jar)",
+        "--add-opens=java.base/java.nio=ALL-UNNAMED",
+        "--add-opens=java.base/java.lang=ALL-UNNAMED",
+    ],
+})
+
+default_java_toolchain(
+    name = "error_prone_warnings_toolchain_java11",
+    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
+    forcibly_disable_header_compilation = False,
+    genclass = ["@bazel_tools//tools/jdk:genclass"],
+    header_compiler = ["@bazel_tools//tools/jdk:turbine"],
+    header_compiler_direct = ["@bazel_tools//tools/jdk:turbine_direct"],
+    ijar = ["@bazel_tools//tools/jdk:ijar"],
+    javabuilder = ["@bazel_tools//tools/jdk:javabuilder"],
+    javac_supports_workers = True,
+    jvm_opts = JDK11_JVM_OPTS,
+    misc = [
+        "-XDskipDuplicateBridges=true",
+        "-g",
+        "-parameters",
+    ],
+    package_configuration = [
+        ":error_prone",
+    ],
+    singlejar = ["@bazel_tools//tools/jdk:singlejar"],
+    source_version = "11",
+    target_version = "11",
+    tools = [
+        "@bazel_tools//tools/jdk:java_compiler_jar",
+        "@bazel_tools//tools/jdk:javac_jar",
+        "@bazel_tools//tools/jdk:jdk_compiler_jar",
+    ],
+    visibility = ["//visibility:public"],
+)
+
 # Error Prone errors enabled by default; see ../.bazelrc for how this is
 # enabled. This warnings list is originally based on:
 # https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 21eed9a..dcc4739 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,5 +1,7 @@
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+load("@npm//@bazel/terser:index.bzl", "terser_minified")
 load("//lib/js:npm.bzl", "NPM_SHA1S", "NPM_VERSIONS")
+load("//tools/bzl:genrule2.bzl", "genrule2")
 
 NPMJS = "NPMJS"
 
@@ -439,7 +441,7 @@
     """Combine html, js, css files and optionally split into js and html bundles."""
     _bundle_rule(pkg = native.package_name(), *args, **kwargs)
 
-def polygerrit_plugin(name, app, srcs = [], deps = [], externs = [], assets = None, plugin_name = None, **kwargs):
+def polygerrit_plugin(name, app, srcs = [], deps = [], assets = None, plugin_name = None, **kwargs):
     """Bundles plugin dependencies for deployment.
 
     This rule bundles all Polymer elements and JS dependencies into .html and .js files.
@@ -449,7 +451,6 @@
     Args:
       name: String, rule name.
       app: String, the main or root source file.
-      externs: Fileset, external definitions that should not be bundled.
       assets: Fileset, additional files to be used by plugin in runtime, exported to "plugins/${name}/static".
       plugin_name: String, plugin name. ${name} is used if not provided.
     """
@@ -473,29 +474,15 @@
     else:
         js_srcs = srcs
 
-    closure_js_library(
-        name = name + "_closure_lib",
-        srcs = js_srcs + externs,
-        convention = "GOOGLE",
-        no_closure_library = True,
-        deps = [
-            "//lib/polymer_externs:polymer_closure",
-            "//polygerrit-ui/app/externs:plugin",
-        ],
+    native.filegroup(
+        name = name + "-src-fg",
+        srcs = js_srcs,
     )
 
-    closure_js_binary(
-        name = name + "_bin",
-        compilation_level = "WHITESPACE_ONLY",
-        defs = [
-            "--polymer_version=2",
-            "--language_out=ECMASCRIPT_2017",
-            "--rewrite_polyfills=false",
-        ],
-        deps = [
-            name + "_closure_lib",
-        ],
-        dependency_mode = "PRUNE_LEGACY",
+    terser_minified(
+        name = name + ".min",
+        sourcemap = False,
+        src = name + "-src-fg",
     )
 
     if html_plugin:
@@ -519,7 +506,7 @@
 
     native.genrule(
         name = name + "_rename_js",
-        srcs = [name + "_bin.js"],
+        srcs = [name + ".min"],
         outs = [plugin_name + ".js"],
         cmd = "cp $< $@",
         output_to_bindir = True,
@@ -549,3 +536,60 @@
         name = name,
         srcs = static_files,
     )
+
+def gerrit_js_bundle(name, srcs, entry_point):
+    """Produces a Gerrit JavaScript bundle archive.
+
+    This rule bundles and minifies the javascript files of a frontend plugin and
+    produces a file archive.
+    Output of this rule is an archive with "${name}.jar" with specific layout for
+    Gerrit frontend plugins. That archive should be provided to gerrit_plugin
+    rule as resource_jars attribute.
+
+    Args:
+      name: Rule name.
+      srcs: Plugin sources.
+      entry_point: Plugin entry_point.
+    """
+
+    bundle = name + "-bundle"
+    minified = name + ".min"
+    main = name + ".js"
+
+    rollup_bundle(
+        name = bundle,
+        srcs = srcs,
+        entry_point = entry_point,
+        format = "iife",
+        rollup_bin = "//tools/node_tools:rollup-bin",
+        sourcemap = "hidden",
+        deps = [
+            "@tools_npm//rollup-plugin-node-resolve",
+        ],
+    )
+
+    terser_minified(
+        name = minified,
+        sourcemap = False,
+        src = bundle,
+    )
+
+    native.genrule(
+        name = name + "_rename_js",
+        srcs = [minified],
+        outs = [main],
+        cmd = "cp $< $@",
+        output_to_bindir = True,
+    )
+
+    genrule2(
+        name = name,
+        srcs = [main],
+        outs = [name + ".jar"],
+        cmd = " && ".join([
+            "mkdir $$TMP/static",
+            "cp $(SRCS) $$TMP/static",
+            "cd $$TMP",
+            "zip -Drq $$ROOT/$@ -g .",
+        ]),
+    )
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 022f209..3e4fc92 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -240,7 +240,7 @@
                                 key=lambda package: get_package_display_name(
                                     package)),
             ))
-    return result
+    return sorted(result, key=lambda license: license.name)
 
 def get_licensed_files(json_licensed_file_dict):
     """Convert json dictionary to LicensedFiles"""
@@ -307,4 +307,4 @@
     return result
 
 if __name__ == "__main__":
-    main()
\ No newline at end of file
+    main()
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index f01418e..adea89e 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -60,7 +60,7 @@
     if len(parts) == 3:
         group_id, artifact_id, version = parts
     elif len(parts) == 4:
-        group_id, artifact_id, version, packaging = parts
+        group_id, artifact_id, version, classifier = parts
     elif len(parts) == 5:
         group_id, artifact_id, version, packaging, classifier = parts
     else:
@@ -126,18 +126,6 @@
 """.format(srcjar = srcjar)
     ctx.file("%s/BUILD" % ctx.path("jar"), contents, False)
 
-    # Compatibility layer for java_import_external from rules_closure
-    contents = """
-{header}
-package(default_visibility = ['//visibility:public'])
-
-alias(
-    name = "{rule_name}",
-    actual = "@{rule_name}//jar",
-)
-\n""".format(rule_name = ctx.name, header = header)
-    ctx.file("BUILD", contents, False)
-
 def _maven_jar_impl(ctx):
     """rule to download a Maven archive."""
     coordinates = _create_coordinates(ctx.attr.artifact)
@@ -170,7 +158,10 @@
     srcjar = None
     if ctx.attr.src_sha1 or ctx.attr.attach_source:
         srcjar = jar + "-src.jar"
-        srcurl = url + "-sources.jar"
+        srcurl = url
+        if coordinates.classifier != None:
+            srcurl = url.replace("-" + coordinates.classifier, "")
+        srcurl += "-sources.jar"
         srcjar_path = ctx.path("jar/" + srcjar)
         args = [python, script, "-o", srcjar_path, "-u", srcurl]
         if ctx.attr.src_sha1:
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 7534501..d445be2 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -2,6 +2,8 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//:version.bzl", "GERRIT_VERSION")
 
+IN_TREE_BUILD_MODE = True
+
 PLUGIN_DEPS = ["//plugins:plugin-lib"]
 
 PLUGIN_DEPS_NEVERLINK = ["//plugins:plugin-lib-neverlink"]
diff --git a/tools/dev-hooks/pre-commit b/tools/dev-hooks/pre-commit
deleted file mode 100755
index af87b7e..0000000
--- a/tools/dev-hooks/pre-commit
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/bin/sh
-#
-# Part of Gerrit Code Review (https://www.gerritcodereview.com/)
-#
-# 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.
-
-
-# To enable this hook:
-# - copy this file or content to ".git/hooks/pre-commit"
-# - (optional if you copied this file) make it executable: `chmod +x .git/hooks/pre-commit`
-
-set -ue
-
-# gitroot, default to .
-gitroot=$(git rev-parse --show-cdup)
-gitroot=${gitroot:-.};
-
-# eslint
-eslint=${gitroot}/node_modules/eslint/bin/eslint.js
-
-# Run eslint over changed frontend code
-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
-    git add ${CHANGED_UI_FILES}
-    exit 0
-  else
-    echo "Failed to fix all linter issues.";
-    exit 1
-  fi
-else
-  echo "No UI files changed"
-  exit 0
-fi
\ No newline at end of file
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 0e27e69..6cfa858 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -23,11 +23,14 @@
 
 MAIN = '//tools/eclipse:classpath'
 AUTO = '//lib/auto:auto-value'
-JRE = '/'.join([
-    'org.eclipse.jdt.launching.JRE_CONTAINER',
-    'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
-    'JavaSE-1.8',
-])
+
+def JRE(java_vers = '11'):
+    return '/'.join([
+        'org.eclipse.jdt.launching.JRE_CONTAINER',
+        'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
+        "JavaSE-%s" % java_vers,
+    ])
+
 # Map of targets to corresponding classpath collector rules
 cp_targets = {
     AUTO: '//tools/eclipse:autovalue_classpath_collect',
@@ -46,9 +49,9 @@
 opts.add_argument('-b', '--batch', action='store_true',
                   dest='batch', help='Bazel batch option')
 opts.add_argument('-j', '--java', action='store',
-                  dest='java', help='Post Java 8 support (9)')
+                  dest='java', help='Legacy Java 1.8 or post Java 11')
 opts.add_argument('-e', '--edge_java', action='store',
-                  dest='edge_java', help='Post Java 9 support (10|11|...)')
+                  dest='edge_java', help='Post Java 11 support (14|...)')
 opts.add_argument('--bazel',
                   help=('name of the bazel executable. Defaults to using'
                         ' bazelisk if found, or bazel if bazelisk is not'
@@ -95,7 +98,9 @@
         if arg == "build":
             build = True
         cmd.append(arg)
-    if custom_java and not edge_java:
+    if custom_java == '1.8':
+        cmd.append('--java_toolchain=//tools:error_prone_warnings_toolchain')
+    elif custom_java and not edge_java:
         cmd.append('--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
         cmd.append('--java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
         if edge_java and build:
@@ -240,7 +245,8 @@
             # Exceptions: both source and lib
             if p.endswith('libquery_parser.jar') or \
                p.endswith('libgerrit-prolog-common.jar') or \
-               p.endswith('com_google_protobuf/libprotobuf_java.jar') or \
+               p.endswith('external/com_google_protobuf/java/core/libcore.jar') or \
+               p.endswith('external/com_google_protobuf/java/core/liblite.jar') or \
                p.endswith('lucene-core-and-backward-codecs-merged_deploy.jar'):
                 lib.add(p)
             if proto_library.match(p) :
@@ -311,7 +317,7 @@
         s = s.replace('.jar', '-src.jar')
         classpathentry('lib', p, s)
 
-    classpathentry('con', JRE)
+    classpathentry('con', JRE(custom_java) if custom_java else JRE())
     classpathentry('output', 'eclipse-out/classes')
     classpathentry('src', '.apt_generated')
     classpathentry('src', '.apt_generated_tests', out="eclipse-out/test")
diff --git a/tools/js/download_bower.py b/tools/js/download_bower.py
index 2a75fc1..3bd9be9 100755
--- a/tools/js/download_bower.py
+++ b/tools/js/download_bower.py
@@ -55,7 +55,7 @@
         raise OSError('Command failed: %s' % ' '.join(cmd))
 
     try:
-        info = json.loads(out)
+        info = json.loads(out.decode('utf8'))
     except ValueError:
         raise ValueError('invalid JSON from %s:\n%s' % (" ".join(cmd), out))
     info_name = info.get('name')
diff --git a/tools/js/eslint-rules/BUILD b/tools/js/eslint-rules/BUILD
new file mode 100644
index 0000000..476c4ff
--- /dev/null
+++ b/tools/js/eslint-rules/BUILD
@@ -0,0 +1,11 @@
+package(default_visibility = ["//visibility:public"])
+
+# To load eslint rules from a directory, we must pass a directory
+# name to it. We can't get the directory name in bazel, but we can calculate
+# use a file from this directory. We are using README.md for it.
+exports_files(["README.md"])
+
+filegroup(
+    name = "eslint-rules-srcs",
+    srcs = glob(["**/*.js"]),
+)
diff --git a/tools/js/eslint-rules/README.md b/tools/js/eslint-rules/README.md
new file mode 100644
index 0000000..b425d74
--- /dev/null
+++ b/tools/js/eslint-rules/README.md
@@ -0,0 +1,74 @@
+# Eslint rules for polygerrit
+This directory contains custom eslint rules for polygerrit.
+
+## ts-imports-js
+This rule must be used only for `.ts` files.
+The rule ensures that:
+* All import paths either a relative paths or module imports.
+```typescript
+// Correct imports
+import './file1'; // relative path
+import '../abc/file2'; // relative path
+import 'module_name/xyz'; // import from the module_name
+
+// Incorrect imports
+import '/usr/home/file3'; // absolute path
+```
+* All *relative* import paths has a short form (i.e. don't include extension):
+```typescript
+// Correct imports
+import './file1'; // relative path without extension
+import data from 'module_name/file2.json'; // file in a module, can be anything
+
+// Incorrect imports
+import './file1.js'; // relative path with extension
+```
+
+* Imported `.js` and `.d.ts` files both exists (only for a relative import path):
+
+Example:
+```
+polygerrit-ui/app
+ |- ex.ts
+ |- abc
+     |- correct_ts.ts
+     |- correct_js.js
+     |- correct_js.d.ts
+     |- incorrect_1.js
+     |- incorrect_2.d.ts
+```
+```typescript
+// The ex.ts file:
+// Correct imports
+import {x} from './abc/correct_js'; // correct_js.js and correct_js.d.ts exist
+import {x} from './abc/correct_ts'; // import from .ts - d.ts is not required
+
+// Incorrect imports
+import {x} from './abc/incorrect_1'; // incorrect_1.d.ts doesn't exist
+import {x} from './abc/incorrect_2'; // incorrect_2.js doesn't exist
+```
+
+To fix the last two imports 2 files must be added: `incorrect_1.d.ts` and
+`incorrect_2.js`.
+
+## goog-module-id
+Enforce correct usage of goog.declareModuleId:
+* The goog.declareModuleId must be used only in `.js` files which have
+associated `.d.ts` files.
+* The module name is correct. The correct module name is constructed from the
+file path using the folowing rules
+rules:
+  1. Get part of the path after the '/polygerrit-ui/app/':
+    `/usr/home/gerrit/polygerrit-ui/app/elements/shared/x/y.js` ->
+    `elements/shared/x/y.js`
+  2. Discard `.js` extension and replace all `/` with `.`:
+    `elements/shared/x/y.js` -> `elements.shared.x.y`
+  3. Add `polygerrit.` prefix:
+    `elements.shared.x.y` -> `polygerrit.elements.shared.x.y`
+    The last string is a module name.
+
+Example:
+```javascript
+// polygerrit-ui/app/elements/shared/x/y.js
+goog.declareModuleId('polygerrit.elements.shared.x.y');
+```
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index bd2bc32..586b1c5 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -40,10 +40,24 @@
             bazel run {name}_test -- --fix $(pwd)/polygerrit-ui/app
     """
     entry_point = "@npm//:node_modules/eslint/bin/eslint.js"
+
+    # There are custom eslint rules in eslint-rules directory. Eslint loads
+    # custom rules from a directory specified with the --rulesdir argument.
+    # When bazel runs eslint, it places the eslint-rules directory into
+    # some location in the filesystem, and the location is not known in advance.
+    # It is not possible to get the directory location in bazel directly.
+    # Instead, we can use dirname to get a directory for a file in the
+    # eslint-rules directory.
+    # README.md is the most "stable" file in the eslint-rules directory
+    # (i.e. it is unlikely will be removed), and we are using it to calculate
+    # exact directory path in bazel.
+    eslint_rules_toplevel_file = "//tools/js/eslint-rules:README.md"
     bin_data = [
         "@npm//eslint:eslint",
         config,
         ignore,
+        "//tools/js/eslint-rules:eslint-rules-srcs",
+        eslint_rules_toplevel_file,
     ] + plugins + data
     common_templated_args = [
         "--ext",
@@ -55,6 +69,9 @@
         "$$(rlocation $(rootpath {}))".format(config),
         "--ignore-path",
         "$$(rlocation $(rootpath {}))".format(ignore),
+        # Load custom rules from eslint-rules directory
+        "--rulesdir",
+        "$$(dirname $$(rlocation $(rootpath {})))".format(eslint_rules_toplevel_file),
     ]
     nodejs_test(
         name = name + "_test",
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 25bb226..10898a4 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.2.15-SNAPSHOT</version>
+  <version>3.3.12-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 b930f41..e327bc2 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.2.15-SNAPSHOT</version>
+  <version>3.3.12-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 d3fe4aa9..f571f21 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.2.15-SNAPSHOT</version>
+  <version>3.3.12-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 1afd6de..38ace2f 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.2.15-SNAPSHOT</version>
+  <version>3.3.12-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/maven/package.bzl b/tools/maven/package.bzl
index b25656d..eacb02b 100644
--- a/tools/maven/package.bzl
+++ b/tools/maven/package.bzl
@@ -40,7 +40,7 @@
         src = {},
         doc = {},
         war = {}):
-    build_cmd = ["bazel_cmd", "build"]
+    build_cmd = ["bazel_cmd", "build", "--java_toolchain=//tools:error_prone_warnings_toolchain_java11"]
     mvn_cmd = ["python", "tools/maven/mvn.py", "-v", version]
     api_cmd = mvn_cmd[:]
     api_targets = []
diff --git a/tools/node_tools/BUILD b/tools/node_tools/BUILD
index 4019542..8aeed017 100644
--- a/tools/node_tools/BUILD
+++ b/tools/node_tools/BUILD
@@ -28,11 +28,26 @@
 
 # Create a tsc_wrapped compiler rule to use in the ts_library
 # compiler attribute when using self-managed dependencies
+# TODO: Would be nice to just use `tsc-bin` below instead.
+# We would prefer to not depend on @bazel/concatjs ...
 nodejs_binary(
     name = "tsc_wrapped-bin",
     # Point bazel to your node_modules to find the entry point
-    data = ["@tools_npm//:node_modules"],
+    data = [
+        "@tools_npm//:node_modules",
+        "@tools_npm//@bazel/concatjs",
+        "@tools_npm//typescript",
+    ],
     # 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",
+    entry_point = "@tools_npm//:node_modules/@bazel/concatjs/internal/tsc_wrapped/tsc_wrapped.js",
+)
+
+# Wrap a typescript into a tsc-bin binary.
+# The tsc-bin can be used as a tool to compile typescript code.
+nodejs_binary(
+    name = "tsc-bin",
+    # Point bazel to your node_modules to find the entry point
+    data = ["@tools_npm//:node_modules"],
+    entry_point = "@tools_npm//:node_modules/typescript/lib/tsc.js",
 )
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index bd7e854..f28ed35 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -1,17 +1,18 @@
-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")
+load("@npm//@bazel/concatjs:index.bzl", "ts_library")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 
 package(default_visibility = ["//visibility:public"])
 
+# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
+# We would prefer to not depend on @bazel/concatjs ...
 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",
+        "@tools_npm//:node_modules",
     ],
 )
 
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
index 3f4955e..49beda3 100644
--- a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
+++ b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
@@ -45,8 +45,12 @@
 export class InsalledPackagesBuilder {
   private readonly rootPathToPackageMap: Map<DirPath, InstalledPackage> = new Map();
 
+  public constructor(private readonly nonPackages: Set<string>) {
+  }
+
   public addPackageJson(packageJsonPath: string) {
     const pack = this.createInstalledPackage(packageJsonPath);
+    if (!pack) return;
     this.rootPathToPackageMap.set(pack.rootPath, pack)
   }
   public addFile(file: string) {
@@ -60,19 +64,23 @@
    * 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 {
+  private createInstalledPackage(packageJsonFile: string): InstalledPackage | undefined {
     const nameParts: Array<string> = [];
     const rootPath = path.dirname(packageJsonFile);
     let currentDir = rootPath;
     while(currentDir != "") {
       const partName = path.basename(currentDir);
       if(partName === "node_modules") {
+        const packageName = nameParts.reverse().join("/");
         const version = JSON.parse(fs.readFileSync(packageJsonFile, {encoding: 'utf-8'}))["version"];
         if(!version) {
+          if (this.nonPackages.has(packageName)) {
+            return undefined;
+          }
           fail(`Can't get version for ${packageJsonFile}`)
         }
         return {
-          name: nameParts.reverse().join("/"),
+          name: packageName,
           rootPath: rootPath,
           version: version,
           files: []
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts
index 9f277e5..7dfb23e 100644
--- a/tools/node_tools/node_modules_licenses/licenses-map.ts
+++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -216,7 +216,13 @@
 
   /** getInstalledPackages Collects information about all installed packages */
   private getInstalledPackages(nodeModulesFiles: ReadonlyArray<string>): InstalledPackage[] {
-    const builder = new InsalledPackagesBuilder();
+    const fullNonPackageNames: string[] = [];
+    for (const p of this.packages) {
+      if (p.nonPackages) {
+        fullNonPackageNames.push(...p.nonPackages.map(name => `${p.name}/${name}`));
+      }
+    }
+    const builder = new InsalledPackagesBuilder(new Set(fullNonPackageNames));
     // 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));
diff --git a/tools/node_tools/node_modules_licenses/package-license-info.ts b/tools/node_tools/node_modules_licenses/package-license-info.ts
index c5cdb0f..79dea09 100644
--- a/tools/node_tools/node_modules_licenses/package-license-info.ts
+++ b/tools/node_tools/node_modules_licenses/package-license-info.ts
@@ -67,4 +67,6 @@
   versions?: string[];
   /** Predicate to select files to apply license. */
   filesFilter?: FilesFilter;
+  /** List of nested directories with package.json files, that are not real packages*/
+  nonPackages?: string[];
 }
diff --git a/tools/node_tools/node_modules_licenses/tsconfig.json b/tools/node_tools/node_modules_licenses/tsconfig.json
index 2854857..cb7bb60 100644
--- a/tools/node_tools/node_modules_licenses/tsconfig.json
+++ b/tools/node_tools/node_modules_licenses/tsconfig.json
@@ -1,12 +1,20 @@
 {
   "compilerOptions": {
+    "plugins": [
+      {
+        "name": "@bazel/tsetse",
+        "disabledRules": [
+          "must-type-assert-json-parse"
+        ]
+      }
+    ],
     "target": "es6",
     "module": "commonjs",
     "allowSyntheticDefaultImports": true,
     "esModuleInterop": true,
     "strict": true,
     "moduleResolution": "node",
-    "outDir": "out",
+    "outDir": "../../../.ts-out/tools/node_modules_licenses", // Not used in bazel,
     "types": ["node"]
   },
   "include": ["*.ts"]
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 2af06b4..038f191 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,9 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^0.41.0",
-    "@bazel/typescript": "^1.0.1",
+    "@bazel/concatjs": "^5.1.0",
+    "@bazel/rollup": "^5.1.0",
+    "@bazel/typescript": "^5.1.0",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
@@ -13,10 +14,10 @@
     "parse5-html-rewriting-stream": "^5.1.1",
     "polymer-bundler": "^4.0.10",
     "polymer-cli": "^1.9.11",
-    "rollup": "^1.27.5",
+    "rollup": "^2.3.4",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "^3.7.4"
+    "typescript": "3.9.5"
   },
   "devDependencies": {},
   "license": "Apache-2.0",
diff --git a/tools/node_tools/polygerrit_app_preprocessor/BUILD b/tools/node_tools/polygerrit_app_preprocessor/BUILD
index b031293..47f2a41 100644
--- a/tools/node_tools/polygerrit_app_preprocessor/BUILD
+++ b/tools/node_tools/polygerrit_app_preprocessor/BUILD
@@ -1,13 +1,14 @@
-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")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+load("@npm//@bazel/concatjs:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
+# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
+# We would prefer to not depend on @bazel/concatjs ...
 ts_library(
     name = "preprocessor",
     srcs = glob(["*.ts"]),
-    node_modules = "@tools_npm//:node_modules",
     tsconfig = "tsconfig.json",
     deps = [
         "//tools/node_tools/utils",
diff --git a/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts b/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
index 24e445d..9f872cc 100644
--- a/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
+++ b/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
@@ -39,7 +39,7 @@
     process.exit(1);
   }
 
-  const jsonRedirects: JSONRedirects = JSON.parse(fs.readFileSync(process.argv[3], {encoding: "utf-8"}));
+  const jsonRedirects: JSONRedirects = JSON.parse(fs.readFileSync(process.argv[3], {encoding: "utf-8"})) as JSONRedirects;
   const redirectsResolver = new RedirectsResolver(jsonRedirects.redirects);
 
   const input = readMultilineParamFile(process.argv[2]);
diff --git a/tools/node_tools/utils/BUILD b/tools/node_tools/utils/BUILD
index fca3c12..0a6e768 100644
--- a/tools/node_tools/utils/BUILD
+++ b/tools/node_tools/utils/BUILD
@@ -1,11 +1,12 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
+load("@npm//@bazel/concatjs:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
+# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
+# We would prefer to not depend on @bazel/concatjs ...
 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/tsconfig.json b/tools/node_tools/utils/tsconfig.json
index 34ffb2f..56ab91b 100644
--- a/tools/node_tools/utils/tsconfig.json
+++ b/tools/node_tools/utils/tsconfig.json
@@ -6,7 +6,7 @@
     "esModuleInterop": true,
     "strict": true,
     "moduleResolution": "node",
-    "outDir": "out"
+    "outDir": "../../../.ts-out/tools/utils" // Not used in bazel
   },
   "include": ["*.ts"]
 }
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 0648c8d..527868b 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -492,20 +492,39 @@
     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==
+"@bazel/concatjs@^5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.1.0.tgz#f4321dec4a225c3ceac41b2dc7ec7c3dd3dd5e21"
+  integrity sha512-sj+vxHVB/swh7awOfQ37h3p/gxSPgLSnUkDt6POrj26qkfi7HrLB1ZkWAPFIIxjEhsBp1LchoHiezjw2GylZQg==
   dependencies:
     protobufjs "6.8.8"
+    source-map-support "0.5.9"
+    tsutils "3.21.0"
+
+"@bazel/rollup@^5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.1.0.tgz#dc858ddc93c9fdb9cc2e7982e632c939c646ebdc"
+  integrity sha512-wEiWdSyVbsycSirSYjR6FGfPGbRNI7sGNAYmrV0hIzYIi+KqXeTNcwKIRSE9PESP3mb0VWbZmHvXvmrWk6daPQ==
+  dependencies:
+    "@bazel/worker" "5.1.0"
+
+"@bazel/typescript@^5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.1.0.tgz#348552355cc92a43f22e637fabce76ed64505548"
+  integrity sha512-E7wYv1tBFtcsFp0YN7Cf9Lv184xOzvT5WJKwZxt+43oq8R5tGmTSuqQwm4c9JmEq6s0eZmwUaRv+WXp9hxsE4A==
+  dependencies:
+    "@bazel/worker" "5.1.0"
+    protobufjs "6.8.8"
     semver "5.6.0"
     source-map-support "0.5.9"
-    tsutils "2.27.2"
+    tsutils "3.21.0"
+
+"@bazel/worker@5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.1.0.tgz#6f1e0f3ef628e3449d424cacd341c9abd09a3735"
+  integrity sha512-u3aU93UtHz3vL6ozezq0jnw83s1cNT4dAnW+vvB7M++YKFlB3CWzZFb0JRJbCp1b6DDe30ML0WOdd3nVYuylpw==
+  dependencies:
+    google-protobuf "^3.6.1"
 
 "@mrmlnc/readdir-enhanced@^2.2.1":
   version "2.2.1"
@@ -949,10 +968,15 @@
   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@^10.1.0":
+  version "10.17.27"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.27.tgz#391cb391c75646c8ad2a7b6ed3bbcee52d1bdf19"
+  integrity sha512-J0oqm9ZfAXaPdwNXMMgAhylw5fhmXkToJd06vuDUSAgEDZ/n/69/69UmyBZbc+zT34UnShuDSBqvim3SPnozJg==
+
+"@types/node@^10.17.12":
+  version "10.17.24"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944"
+  integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==
 
 "@types/node@^4.0.30":
   version "4.9.4"
@@ -3727,6 +3751,11 @@
     bindings "^1.5.0"
     nan "^2.12.1"
 
+fsevents@~2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
+  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -3948,6 +3977,11 @@
     pify "^3.0.0"
     slash "^1.0.0"
 
+google-protobuf@^3.6.1:
+  version "3.19.4"
+  resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.19.4.tgz#8d32c3e34be9250956f28c0fb90955d13f311888"
+  integrity sha512-OIPNCxsG2lkIvf+P5FNfJ/Km95CsXOBecS9ZcAU6m2Rq3svc0Apl9nB3GMDNKfQ9asNv4KjyAqGwPQFrVle3Yg==
+
 got@^5.0.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35"
@@ -6822,7 +6856,7 @@
   dependencies:
     estree-walker "^0.6.1"
 
-rollup@^1.27.5, rollup@^1.3.0:
+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==
@@ -6831,6 +6865,13 @@
     "@types/node" "*"
     acorn "^7.1.0"
 
+rollup@^2.3.4:
+  version "2.35.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.35.1.tgz#e6bc8d10893556a638066f89e8c97f422d03968c"
+  integrity sha512-q5KxEyWpprAIcainhVy6HfRttD9kutQpHbeqDTWnqAFNJotiojetK6uqmcydNMymBEtC4I8bCYR+J3mTMqeaUA==
+  optionalDependencies:
+    fsevents "~2.1.2"
+
 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"
@@ -7829,15 +7870,20 @@
   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:
+tslib@^1.8.1:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+
+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==
+tsutils@3.21.0:
+  version "3.21.0"
+  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
+  integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==
   dependencies:
     tslib "^1.8.1"
 
@@ -7871,10 +7917,10 @@
   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==
+typescript@3.9.5:
+  version "3.9.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36"
+  integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index d3f5543..3c4044b 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -1,4 +1,5 @@
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
 
 def declare_nongoogle_deps():
     """loads dependencies that are not used at Google.
@@ -9,6 +10,38 @@
     """
 
     maven_jar(
+        name = "log4j",
+        artifact = "ch.qos.reload4j:reload4j:1.2.19",
+        sha1 = "4eae9978468c5e885a6fb44df7e2bbc07a20e6ce",
+    )
+
+    SLF4J_VERS = "1.7.36"
+
+    maven_jar(
+        name = "log-api",
+        artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
+        sha1 = "6c62681a2f655b49963a5983b8b0950a6120ae14",
+    )
+
+    maven_jar(
+        name = "log-ext",
+        artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
+        sha1 = "99f282aea4b6dbca04d00f0ade6e5ed61ee7091a",
+    )
+
+    maven_jar(
+        name = "impl-log4j",
+        artifact = "org.slf4j:slf4j-reload4j:" + SLF4J_VERS,
+        sha1 = "db708f7d959dee1857ac524636e85ecf2e1781c1",
+    )
+
+    maven_jar(
+        name = "jcl-over-slf4j",
+        artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
+        sha1 = "d877e195a05aca4a2f1ad2ff14bfec1393af4b5e",
+    )
+
+    maven_jar(
         name = "j2objc",
         artifact = "com.google.j2objc:j2objc-annotations:1.1",
         sha1 = "ed28ded51a8b1c6b112568def5f4b455e6809019",
@@ -107,27 +140,63 @@
         sha1 = "c2351800432bdbdd8284c3f5a7f0782a352aa84a",
     )
 
+    maven_jar(
+        name = "commons-io",
+        artifact = "commons-io:commons-io:2.4",
+        sha1 = "b1b6ea3b7e4aa4f492509a4952029cd8e48019ad",
+    )
+
     # Google internal dependencies: these are developed at Google, so there is
     # no concern about version skew.
 
-    FLOGGER_VERS = "0.4"
+    FLOGGER_VERS = "0.5.1"
 
     maven_jar(
         name = "flogger",
         artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-        sha1 = "9c8863dcc913b56291c0c88e6d4ca9715b43df98",
+        sha1 = "71d1e2cef9cc604800825583df56b8ef5c053f14",
     )
 
     maven_jar(
         name = "flogger-log4j-backend",
         artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS,
-        sha1 = "17aa5e31daa1354187e14b6978597d630391c028",
+        sha1 = "5e2794b75c88223f263f1c1a9d7ea51e2dc45732",
     )
 
     maven_jar(
         name = "flogger-system-backend",
         artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
-        sha1 = "287b569d76abcd82f9de87fe41829fbc7ebd8ac9",
+        sha1 = "b66d3bedb14da604828a8693bb24fd78e36b0e9e",
+    )
+
+    GUICE_VERS = "4.2.3"
+
+    GUICE_LIBRARY_SHA256 = "5168f5e7383f978c1b4154ac777b78edd8ac214bb9f9afdb92921c8d156483d3"
+
+    http_file(
+        name = "guice-library-no-aop",
+        canonical_id = "guice-library-no-aop-" + GUICE_VERS + ".jar-" + GUICE_LIBRARY_SHA256,
+        downloaded_file_path = "guice-library-no-aop.jar",
+        sha256 = GUICE_LIBRARY_SHA256,
+        urls = [
+            "https://repo1.maven.org/maven2/com/google/inject/guice/" +
+            GUICE_VERS +
+            "/guice-" +
+            GUICE_VERS +
+            "-no_aop.jar",
+        ],
+    )
+
+    maven_jar(
+        name = "guice-assistedinject",
+        artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
+        sha1 = "acbfddc556ee9496293ed1df250cc378f331d854",
+    )
+
+    maven_jar(
+        name = "guice-servlet",
+        artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
+        sha1 = "8d6e7e35eac4fb5e7df19c55b3bc23fa51b10a11",
     )
 
     # Test-only dependencies below.
diff --git a/tools/release_noter/.editorconfig b/tools/release_noter/.editorconfig
new file mode 100644
index 0000000..9d2865f
--- /dev/null
+++ b/tools/release_noter/.editorconfig
@@ -0,0 +1,2 @@
+[*.py]
+indent_size = 4
diff --git a/tools/release_noter/.flake8 b/tools/release_noter/.flake8
new file mode 100644
index 0000000..24f2db7
--- /dev/null
+++ b/tools/release_noter/.flake8
@@ -0,0 +1,5 @@
+[flake8]
+max-line-length = 100
+extend-ignore =
+    # https://github.com/PyCQA/pycodestyle/issues/373
+    E203,
diff --git a/tools/release_noter/.gitignore b/tools/release_noter/.gitignore
new file mode 100644
index 0000000..c791f63
--- /dev/null
+++ b/tools/release_noter/.gitignore
@@ -0,0 +1,2 @@
+/.idea/
+/release_noter*.md
diff --git a/tools/release_noter/Makefile b/tools/release_noter/Makefile
new file mode 100644
index 0000000..f18a814
--- /dev/null
+++ b/tools/release_noter/Makefile
@@ -0,0 +1,26 @@
+COMMITS := 10
+
+.PHONY: all clean
+
+all: deploy black flake test
+
+clean:
+	rm -f release_noter*.md
+
+setup:
+	pipenv install --dev
+
+deploy:
+	pipenv install --dev --deploy
+
+black:
+	pipenv run black release_noter.py
+
+flake:
+	pipenv run flake8 release_noter.py
+
+help:
+	pipenv run python release_noter.py -h
+
+test:
+	pipenv run python release_noter.py HEAD~$(COMMITS)..HEAD -l
diff --git a/tools/release_noter/Pipfile b/tools/release_noter/Pipfile
new file mode 100644
index 0000000..8e67cf8
--- /dev/null
+++ b/tools/release_noter/Pipfile
@@ -0,0 +1,15 @@
+[[source]]
+name = "pypi"
+url = "https://pypi.org/simple"
+verify_ssl = true
+
+[dev-packages]
+black = { version = "==20.8b1", markers = "python_version >= '3.8'" }
+flake8 = { version = "==3.8.4", markers = "python_version >= '3.8'" }
+
+[packages]
+jinja2 = { version = "==2.11.2", markers = "python_version >= '3.8'" }
+pygerrit2 = { version = "==2.0.13", markers = "python_version >= '3.8'" }
+
+[requires]
+python_version = "3.8"
diff --git a/tools/release_noter/Pipfile.lock b/tools/release_noter/Pipfile.lock
new file mode 100644
index 0000000..7454fe7
--- /dev/null
+++ b/tools/release_noter/Pipfile.lock
@@ -0,0 +1,266 @@
+{
+    "_meta": {
+        "hash": {
+            "sha256": "66a7d7fdb0a62b702f5414852b80c579a3c16d7a4ed1f3b5344943437c6157ee"
+        },
+        "pipfile-spec": 6,
+        "requires": {
+            "python_version": "3.8"
+        },
+        "sources": [
+            {
+                "name": "pypi",
+                "url": "https://pypi.org/simple",
+                "verify_ssl": true
+            }
+        ]
+    },
+    "default": {
+        "certifi": {
+            "hashes": [
+                "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
+                "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
+            ],
+            "version": "==2020.6.20"
+        },
+        "chardet": {
+            "hashes": [
+                "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+                "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+            ],
+            "version": "==3.0.4"
+        },
+        "idna": {
+            "hashes": [
+                "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+                "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.10"
+        },
+        "jinja2": {
+            "hashes": [
+                "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
+                "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==2.11.2"
+        },
+        "markupsafe": {
+            "hashes": [
+                "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
+                "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
+                "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
+                "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
+                "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
+                "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
+                "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
+                "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
+                "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
+                "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
+                "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
+                "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
+                "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
+                "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
+                "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
+                "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
+                "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
+                "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
+                "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
+                "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
+                "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
+                "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
+                "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
+                "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
+                "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
+                "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
+                "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
+                "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
+                "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
+                "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
+                "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
+                "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
+                "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==1.1.1"
+        },
+        "pbr": {
+            "hashes": [
+                "sha256:14bfd98f51c78a3dd22a1ef45cf194ad79eee4a19e8e1a0d5c7f8e81ffe182ea",
+                "sha256:5adc0f9fc64319d8df5ca1e4e06eea674c26b80e6f00c530b18ce6a6592ead15"
+            ],
+            "markers": "python_version >= '2.6'",
+            "version": "==5.5.0"
+        },
+        "pygerrit2": {
+            "hashes": [
+                "sha256:4e3c66017e02833bb9302f98fca47fb21cc01d5d2281d62eaefa18e8bd2c2c08"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==2.0.13"
+        },
+        "requests": {
+            "hashes": [
+                "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
+                "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==2.24.0"
+        },
+        "urllib3": {
+            "hashes": [
+                "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
+                "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+            "version": "==1.25.10"
+        }
+    },
+    "develop": {
+        "appdirs": {
+            "hashes": [
+                "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
+                "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
+            ],
+            "version": "==1.4.4"
+        },
+        "black": {
+            "hashes": [
+                "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==20.8b1"
+        },
+        "click": {
+            "hashes": [
+                "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
+                "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==7.1.2"
+        },
+        "flake8": {
+            "hashes": [
+                "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
+                "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==3.8.4"
+        },
+        "mccabe": {
+            "hashes": [
+                "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
+                "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
+            ],
+            "version": "==0.6.1"
+        },
+        "mypy-extensions": {
+            "hashes": [
+                "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
+                "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
+            ],
+            "version": "==0.4.3"
+        },
+        "pathspec": {
+            "hashes": [
+                "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
+                "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
+            ],
+            "version": "==0.8.0"
+        },
+        "pycodestyle": {
+            "hashes": [
+                "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
+                "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.6.0"
+        },
+        "pyflakes": {
+            "hashes": [
+                "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
+                "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.2.0"
+        },
+        "regex": {
+            "hashes": [
+                "sha256:02686a2f0b1a4be0facdd0d3ad4dc6c23acaa0f38fb5470d892ae88584ba705c",
+                "sha256:137da580d1e6302484be3ef41d72cf5c3ad22a076070051b7449c0e13ab2c482",
+                "sha256:20cdd7e1736f4f61a5161aa30d05ac108ab8efc3133df5eb70fe1e6a23ea1ca6",
+                "sha256:25991861c6fef1e5fd0a01283cf5658c5e7f7aa644128e85243bc75304e91530",
+                "sha256:26b85672275d8c7a9d4ff93dbc4954f5146efdb2ecec89ad1de49439984dea14",
+                "sha256:2f60ba5c33f00ce9be29a140e6f812e39880df8ba9cb92ad333f0016dbc30306",
+                "sha256:3dd952f3f8dc01b72c0cf05b3631e05c50ac65ddd2afdf26551638e97502107b",
+                "sha256:578ac6379e65eb8e6a85299b306c966c852712c834dc7eef0ba78d07a828f67b",
+                "sha256:5d4a3221f37520bb337b64a0632716e61b26c8ae6aaffceeeb7ad69c009c404b",
+                "sha256:608d6c05452c0e6cc49d4d7407b4767963f19c4d2230fa70b7201732eedc84f2",
+                "sha256:65b6b018b07e9b3b6a05c2c3bb7710ed66132b4df41926c243887c4f1ff303d5",
+                "sha256:698f8a5a2815e1663d9895830a063098ae2f8f2655ae4fdc5dfa2b1f52b90087",
+                "sha256:6c72adb85adecd4522a488a751e465842cdd2a5606b65464b9168bf029a54272",
+                "sha256:6d4cdb6c20e752426b2e569128488c5046fb1b16b1beadaceea9815c36da0847",
+                "sha256:6e9f72e0ee49f7d7be395bfa29e9533f0507a882e1e6bf302c0a204c65b742bf",
+                "sha256:828618f3c3439c5e6ef8621e7c885ca561bbaaba90ddbb6a7dfd9e1ec8341103",
+                "sha256:85b733a1ef2b2e7001aff0e204a842f50ad699c061856a214e48cfb16ace7d0c",
+                "sha256:8958befc139ac4e3f16d44ec386c490ea2121ed8322f4956f83dd9cad8e9b922",
+                "sha256:a51e51eecdac39a50ede4aeed86dbef4776e3b73347d31d6ad0bc9648ba36049",
+                "sha256:aeac7c9397480450016bc4a840eefbfa8ca68afc1e90648aa6efbfe699e5d3bb",
+                "sha256:aef23aed9d4017cc74d37f703d57ce254efb4c8a6a01905f40f539220348abf9",
+                "sha256:af1f5e997dd1ee71fb6eb4a0fb6921bf7a778f4b62f1f7ef0d7445ecce9155d6",
+                "sha256:b5eeaf4b5ef38fab225429478caf71f44d4a0b44d39a1aa4d4422cda23a9821b",
+                "sha256:d25f5cca0f3af6d425c9496953445bf5b288bb5b71afc2b8308ad194b714c159",
+                "sha256:d81be22d5d462b96a2aa5c512f741255ba182995efb0114e5a946fe254148df1",
+                "sha256:e935a166a5f4c02afe3f7e4ce92ce5a786f75c6caa0c4ce09c922541d74b77e8",
+                "sha256:ef3a55b16c6450574734db92e0a3aca283290889934a23f7498eaf417e3af9f0"
+            ],
+            "version": "==2020.10.15"
+        },
+        "toml": {
+            "hashes": [
+                "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
+                "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
+            ],
+            "version": "==0.10.1"
+        },
+        "typed-ast": {
+            "hashes": [
+                "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
+                "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
+                "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
+                "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
+                "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
+                "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
+                "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
+                "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
+                "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
+                "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
+                "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
+                "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
+                "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
+                "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
+                "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
+                "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
+                "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
+                "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
+                "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
+                "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
+                "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
+            ],
+            "version": "==1.4.1"
+        },
+        "typing-extensions": {
+            "hashes": [
+                "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
+                "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
+                "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
+            ],
+            "version": "==3.7.4.3"
+        }
+    }
+}
diff --git a/tools/release_noter/README.md b/tools/release_noter/README.md
new file mode 100644
index 0000000..449522b
--- /dev/null
+++ b/tools/release_noter/README.md
@@ -0,0 +1,53 @@
+# Release Noter
+
+## Setup
+
+```bash
+make setup
+make deploy
+```
+
+* The `deploy` target may not succeed if `Pipfile.lock` is out of date.
+  * The `setup` target can be used first in such a case.
+* Using `make all` will run the `deploy` target, among the other key targets.
+
+## Warning
+
+The make `clean` target removes any previously made `release_noter*.md` file(s).
+
+Running `release_noter.py` multiple times without cleaning creates the next `N`
+`release_noter-N.md` file, without overwriting the previous one(s).
+
+## Usage
+
+```bash
+make help
+```
+
+* The resulting `release_noter*.md` file(s) can be edited then copied over to the `homepage`.
+  * The markdown file name should be `x.y.md`, where `x.y` is the major release version.
+  * Alternatively, an existing `x.y.md` can be edited with `release_noter*.md` snippets.
+
+## Testing
+
+```bash
+make test
+make test COMMITS=100
+```
+
+This target will use the `-l` option, which takes more time as `COMMITS` increases.
+
+## Examples
+
+```bash
+pipenv run python release_noter.py v3.2.3..HEAD
+pipenv run python release_noter.py v3.2.3..v3.3.0-rc0
+pipenv run python release_noter.py v3.2.3..v3.3.0-rc0 -l
+```
+
+## Coding
+
+```bash
+make black
+make flake
+```
diff --git a/tools/release_noter/release_noter.md.template b/tools/release_noter/release_noter.md.template
new file mode 100644
index 0000000..06399a1
--- /dev/null
+++ b/tools/release_noter/release_noter.md.template
@@ -0,0 +1,36 @@
+---
+title: "Gerrit {{ data.new }} release (in development)"
+permalink: {{ data.major }}.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: true
+---
+
+Download: **[{{ data.new }}](https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.new }}.war)**
+| [{{ data.previous }}](https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.previous }}.war)
+
+Documentation: **[{{ data.new }}](https://gerrit-documentation.storage.googleapis.com/Documentation/{{ data.doc }}/index.html)**
+| [{{ data.previous }}](https://gerrit-documentation.storage.googleapis.com/Documentation/{{ data.previous }}/index.html)
+
+## Release highlights
+
+## Important notes
+
+### Schema changes
+
+### Breaking changes
+
+## Native packaging
+
+## New features
+
+### REST APIs
+
+* Accounts
+* Changes
+* Groups
+* Projects
+
+## End-to-end tests
+
+## Plugin changes
diff --git a/tools/release_noter/release_noter.py b/tools/release_noter/release_noter.py
new file mode 100644
index 0000000..05fa023
--- /dev/null
+++ b/tools/release_noter/release_noter.py
@@ -0,0 +1,363 @@
+#!/usr/bin/env python
+
+import argparse
+import os
+import re
+import subprocess
+
+from enum import Enum
+from jinja2 import Template
+from os import path
+from pygerrit2 import Anonymous, GerritRestAPI
+
+EXCLUDED_SUBJECTS = {
+    "annotat",
+    "assert",
+    "AutoValue",
+    "avadoc",  # Javadoc &co.
+    "avaDoc",
+    "ava-doc",
+    "baz",  # bazel, bazlet(s)
+    "Baz",
+    "circular",
+    "class",
+    "common.ts",
+    "construct",
+    "controls",
+    "debounce",
+    "Debounce",
+    "decorat",
+    "efactor",  # Refactor &co.
+    "format",
+    "Format",
+    "getter",
+    "gr-",
+    "hide",
+    "icon",
+    "ignore",
+    "immutab",
+    "import",
+    "inject",
+    "iterat",
+    "IT",
+    "js",
+    "label",
+    "licence",
+    "license",
+    "lint",
+    "listener",
+    "Listener",
+    "lock",
+    "method",
+    "metric",
+    "mock",
+    "module",
+    "naming",
+    "nits",
+    "nongoogle",
+    "prone",  # error prone &co.
+    "Prone",
+    "register",
+    "Register",
+    "remove",
+    "Remove",
+    "rename",
+    "Rename",
+    "Revert",
+    "serializ",
+    "Serializ",
+    "server.go",
+    "setter",
+    "spell",
+    "Spell",
+    "test",  # testing, tests; unit or else
+    "Test",
+    "thread",
+    "tsetse",
+    "type",
+    "Type",
+    "typo",
+    "util",
+    "variable",
+    "version",
+    "warning",
+}
+
+COMMIT_SHA1_PATTERN = r"^commit ([a-z0-9]+)$"
+DATE_HEADER_PATTERN = r"Date: .+"
+SUBJECT_SUBMODULES_PATTERN = r"^Update git submodules$"
+ISSUE_ID_PATTERN = r"[a-zA-Z]+: [Ii]ssue ([0-9]+)"
+CHANGE_ID_PATTERN = r"^Change-Id: [I0-9a-z]+$"
+PLUGIN_PATTERN = r"plugins/([a-z\-]+)"
+RELEASE_VERSIONS_PATTERN = r"v([0-9\.\-rc]+)\.\.v([0-9\.\-rc]+)"
+RELEASE_MAJOR_PATTERN = r"^([0-9]+\.[0-9]+).+"
+RELEASE_DOC_PATTERN = r"^([0-9]+\.[0-9]+\.[0-9]+).*"
+
+CHANGE_URL = "/c/gerrit/+/"
+COMMIT_URL = "/changes/?q=commit%3A"
+GERRIT_URL = "https://gerrit-review.googlesource.com"
+ISSUE_URL = "https://bugs.chromium.org/p/gerrit/issues/detail?id="
+
+MARKDOWN = "release_noter"
+GIT_COMMAND = "git"
+GIT_PATH = "../.."
+PLUGINS = "plugins/"
+UTF8 = "UTF-8"
+
+
+def parse_args():
+    parser = argparse.ArgumentParser(
+        description="Generate an initial release notes markdown file.",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+    )
+    parser.add_argument(
+        "-l",
+        "--link",
+        dest="link",
+        required=False,
+        default=False,
+        action="store_true",
+        help="link commits to change in Gerrit; slower as it gets each _number from gerrit",
+    )
+    parser.add_argument("range", help="git log revision range")
+    return parser.parse_args()
+
+
+def list_submodules():
+    submodule_names = [
+        GIT_COMMAND,
+        "submodule",
+        "foreach",
+        "--quiet",
+        "echo $name",
+    ]
+    return subprocess.check_output(submodule_names, cwd=f"{GIT_PATH}", encoding=UTF8)
+
+
+def open_git_log(options, cwd=os.getcwd()):
+    git_log = [
+        GIT_COMMAND,
+        "log",
+        "--no-merges",
+        options.range,
+    ]
+    return subprocess.check_output(git_log, cwd=cwd, encoding=UTF8)
+
+
+class Component:
+    name = None
+    sentinels = set()
+
+    def __init__(self, name, sentinels):
+        self.name = name
+        self.sentinels = sentinels
+
+
+class Components(Enum):
+    plugin_ce = Component("Codemirror-editor", {PLUGINS})
+    plugin_cm = Component("Commit-message-length-validator", {PLUGINS})
+    plugin_dp = Component("Delete-project", {PLUGINS})
+    plugin_dc = Component("Download-commands", {PLUGINS})
+    plugin_gt = Component("Gitiles", {PLUGINS})
+    plugin_ho = Component("Hooks", {PLUGINS})
+    plugin_pm = Component("Plugin-manager", {PLUGINS})
+    plugin_re = Component("Replication", {PLUGINS})
+    plugin_rn = Component("Reviewnotes", {PLUGINS})
+    plugin_su = Component("Singleusergroup", {PLUGINS})
+    plugin_wh = Component("Webhooks", {PLUGINS})
+
+    ui = Component(
+        "Polygerrit UI",
+        {"poly", "gwt", "button", "dialog", "icon", "hover", "menu", "ux"},
+    )
+    doc = Component("Documentation", {"document"})
+    jgit = Component("JGit", {"jgit"})
+    elastic = Component("Elasticsearch", {"elastic"})
+    deps = Component("Other dependency", {"upgrade", "dependenc"})
+    otherwise = Component("Other core", {})
+
+
+class Task(Enum):
+    start_commit = 1
+    finish_headers = 2
+    capture_subject = 3
+    finish_commit = 4
+
+
+class Commit:
+    sha1 = None
+    subject = None
+    component = None
+    issues = set()
+
+    def reset(self, signature, task):
+        if signature is not None:
+            self.sha1 = signature.group(1)
+            self.subject = None
+            self.component = None
+            self.issues = set()
+            return Task.finish_headers
+        return task
+
+
+def parse_log(process, gerrit, options, commits, cwd=os.getcwd()):
+    commit = Commit()
+    task = Task.start_commit
+    for line in process.splitlines():
+        line = line.strip()
+        if not line:
+            continue
+        if task == Task.start_commit:
+            task = commit.reset(re.search(COMMIT_SHA1_PATTERN, line), task)
+        elif task == Task.finish_headers:
+            if re.match(DATE_HEADER_PATTERN, line):
+                task = Task.capture_subject
+        elif task == Task.capture_subject:
+            commit.subject = line
+            task = Task.finish_commit
+        elif task == Task.finish_commit:
+            commit_issue = re.search(ISSUE_ID_PATTERN, line)
+            if commit_issue is not None:
+                commit.issues.add(commit_issue.group(1))
+            else:
+                commit_end = re.match(CHANGE_ID_PATTERN, line)
+                if commit_end is not None:
+                    commit = finish(commit, commits, gerrit, options, cwd)
+                    task = Task.start_commit
+        else:
+            raise RuntimeError("FIXME")
+
+
+def finish(commit, commits, gerrit, options, cwd):
+    if re.match(SUBJECT_SUBMODULES_PATTERN, commit.subject):
+        return Commit()
+    if len(commit.issues) == 0:
+        for exclusion in EXCLUDED_SUBJECTS:
+            if exclusion in commit.subject:
+                return Commit()
+        for component in commits:
+            for noted_commit in commits[component]:
+                if noted_commit.subject == commit.subject:
+                    return Commit()
+    set_component(commit, commits, cwd)
+    link_subject(commit, gerrit, options, cwd)
+    escape_these(commit)
+    return Commit()
+
+
+def set_component(commit, commits, cwd):
+    component_found = None
+    for component in Components:
+        for sentinel in component.value.sentinels:
+            if component_found is None:
+                if re.match(f"{GIT_PATH}/{PLUGINS}{component.value.name.lower()}", cwd):
+                    component_found = component
+                elif sentinel.lower() in commit.subject.lower():
+                    component_found = component
+                if component_found is not None:
+                    commits[component].append(commit)
+    if component_found is None:
+        commits[Components.otherwise].append(commit)
+    commit.component = component_found
+
+
+def init_components():
+    components = dict()
+    for component in Components:
+        components[component] = []
+    return components
+
+
+def link_subject(commit, gerrit, options, cwd):
+    if options.link:
+        gerrit_change = gerrit.get(f"{COMMIT_URL}{commit.sha1}")
+        if not gerrit_change:
+            return
+        change_number = gerrit_change[0]["_number"]
+        plugin_wd = re.search(f"{GIT_PATH}/({PLUGINS}.+)", cwd)
+        if plugin_wd is not None:
+            change_address = f"{GERRIT_URL}/c/{plugin_wd.group(1)}/+/{change_number}"
+        else:
+            change_address = f"{GERRIT_URL}{CHANGE_URL}{change_number}"
+        short_sha1 = commit.sha1[0:7]
+        commit.subject = f"[{short_sha1}]({change_address})\n  {commit.subject}"
+
+
+def escape_these(in_change):
+    in_change.subject = in_change.subject.replace("<", "\\<")
+    in_change.subject = in_change.subject.replace(">", "\\>")
+
+
+def print_commits(commits, md):
+    for component in commits:
+        if len(commits[component]) > 0:
+            if PLUGINS in component.value.sentinels:
+                md.write(f"\n### {component.value.name}\n")
+            else:
+                md.write(f"\n## {component.value.name} changes\n")
+            for commit in commits[component]:
+                print_from(commit, md)
+
+
+def print_from(this_change, md):
+    md.write("\n*")
+    for issue in sorted(this_change.issues):
+        md.write(f" [Issue {issue}]({ISSUE_URL}{issue});\n ")
+    md.write(f" {this_change.subject}\n")
+
+
+def print_template(md, options):
+    previous = "0.0.0"
+    new = "0.1.0"
+    versions = re.search(RELEASE_VERSIONS_PATTERN, options.range)
+    if versions is not None:
+        previous = versions.group(1)
+        new = versions.group(2)
+    data = {
+        "previous": previous,
+        "new": new,
+        "major": re.search(RELEASE_MAJOR_PATTERN, new).group(1),
+        "doc": re.search(RELEASE_DOC_PATTERN, new).group(1),
+    }
+    template = Template(open(f"{MARKDOWN}.md.template").read())
+    md.write(f"{template.render(data=data)}\n")
+
+
+def print_notes(commits, options):
+    markdown = f"{MARKDOWN}.md"
+    next_md = 2
+    while path.exists(markdown):
+        markdown = f"{MARKDOWN}-{next_md}.md"
+        next_md += 1
+    with open(markdown, "w") as md:
+        print_template(md, options)
+        print_commits(commits, md)
+        md.write("\n## Bugfix releases\n")
+
+
+def plugin_changes():
+    plugin_commits = init_components()
+    for submodule_name in list_submodules().splitlines():
+        plugin_name = re.search(PLUGIN_PATTERN, submodule_name)
+        if plugin_name is not None:
+            plugin_wd = f"{GIT_PATH}/{PLUGINS}{plugin_name.group(1)}"
+            plugin_log = open_git_log(script_options, plugin_wd)
+            parse_log(
+                plugin_log,
+                gerrit_api,
+                script_options,
+                plugin_commits,
+                plugin_wd,
+            )
+    return plugin_commits
+
+
+if __name__ == "__main__":
+    gerrit_api = GerritRestAPI(url=GERRIT_URL, auth=Anonymous())
+    script_options = parse_args()
+    if script_options.link:
+        print("Link option used; slower.")
+    noted_changes = plugin_changes()
+    change_log = open_git_log(script_options)
+    parse_log(change_log, gerrit_api, script_options, noted_changes)
+    print_notes(noted_changes, script_options)
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index 13c498e..0aeb8d5 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -30,36 +30,20 @@
 
 # Set several flags related to specifying the platform, toolchain and java
 # properties.
-# These flags are duplicated rather than imported from (for example)
-# %workspace%/configs/ubuntu16_04_clang/1.2/toolchain.bazelrc to make this
-# bazelrc a standalone file that can be copied more easily.
-# These flags should only be used as is for the rbe-ubuntu16-04 container
-# and need to be adapted to work with other toolchain containers.
-build:remote --host_javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
-build:remote --javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
-build:remote --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8
-build:remote --java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8
-build:remote --crosstool_top=@bazel_toolchains//configs/ubuntu16_04_clang/1.2/bazel_0.25.0/default:toolchain
+build:remote --host_javabase=@rbe_jdk11//java:jdk
+build:remote --javabase=@rbe_jdk11//java:jdk
+build:remote --crosstool_top=@rbe_jdk11//cc:toolchain
+build:remote --extra_toolchains=@rbe_jdk11//config:cc-toolchain
+build:remote --extra_execution_platforms=@rbe_jdk11//config:platform
+build:remote --host_platform=@rbe_jdk11//config:platform
+build:remote --platforms=@rbe_jdk11//config:platform
 build:remote --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
-# Platform flags:
-# The toolchain container used for execution is defined in the target indicated
-# by "extra_execution_platforms", "host_platform" and "platforms".
-# If you are using your own toolchain container, you need to create a platform
-# target with "constraint_values" that allow for the toolchain specified with
-# "extra_toolchains" to be selected (given constraints defined in
-# "exec_compatible_with").
-# More about platforms: https://docs.bazel.build/versions/master/platforms.html
-build:remote --extra_toolchains=@bazel_toolchains//configs/ubuntu16_04_clang/1.2/bazel_0.25.0/cpp:cc-toolchain-clang-x86_64-default
-build:remote --extra_execution_platforms=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:rbe_ubuntu1604
-build:remote --host_platform=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:rbe_ubuntu1604
-build:remote --platforms=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:rbe_ubuntu1604
 
 # 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,sandboxed
 build:remote --strategy=Javac=remote
-build:remote --strategy=Closure=remote
 build:remote --strategy=Genrule=remote
 build:remote --define=EXECUTOR=remote
 
@@ -78,20 +62,6 @@
 # account credential instead.
 build:remote --auth_enabled=true
 
-# The following flags are only necessary for local docker sandboxing
-# with the rbe-ubuntu16-04 container. Use of these flags is still experimental.
-build:docker-sandbox --host_javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
-build:docker-sandbox --javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
-build:docker-sandbox --crosstool_top=@bazel_toolchains//configs/ubuntu16_04_clang/1.2/bazel_0.25.0/default:toolchain
-build:docker-sandbox --experimental_docker_image=gcr.io/cloud-marketplace/google/rbe-ubuntu16-04@sha256:da0f21c71abce3bbb92c3a0c44c3737f007a82b60f8bd2930abc55fe64fc2729
-build:docker-sandbox --spawn_strategy=docker
-build:docker-sandbox --strategy=Javac=docker
-build:docker-sandbox --strategy=Closure=docker
-build:docker-sandbox --strategy=Genrule=docker
-build:docker-sandbox --define=EXECUTOR=remote
-build:docker-sandbox --experimental_docker_verbose
-build:docker-sandbox --experimental_enable_docker_sandbox
-
 # The following flags enable the remote cache so action results can be shared
 # across machines, developers, and workspaces.
 build:remote-cache --remote_cache=remotebuildexecution.googleapis.com
@@ -100,5 +70,4 @@
 build:remote-cache --auth_enabled=true
 build:remote-cache --spawn_strategy=standalone
 build:remote-cache --strategy=Javac=standalone
-build:remote-cache --strategy=Closure=standalone
 build:remote-cache --strategy=Genrule=standalone
diff --git a/version.bzl b/version.bzl
index c003386..7b5e75c 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.2.15-SNAPSHOT"
+GERRIT_VERSION = "3.3.12-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index 820cca3..d20d82e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -485,20 +485,44 @@
     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==
+"@bazel/concatjs@^5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.1.0.tgz#f4321dec4a225c3ceac41b2dc7ec7c3dd3dd5e21"
+  integrity sha512-sj+vxHVB/swh7awOfQ37h3p/gxSPgLSnUkDt6POrj26qkfi7HrLB1ZkWAPFIIxjEhsBp1LchoHiezjw2GylZQg==
   dependencies:
     protobufjs "6.8.8"
+    source-map-support "0.5.9"
+    tsutils "3.21.0"
+
+"@bazel/rollup@^5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.1.0.tgz#dc858ddc93c9fdb9cc2e7982e632c939c646ebdc"
+  integrity sha512-wEiWdSyVbsycSirSYjR6FGfPGbRNI7sGNAYmrV0hIzYIi+KqXeTNcwKIRSE9PESP3mb0VWbZmHvXvmrWk6daPQ==
+  dependencies:
+    "@bazel/worker" "5.1.0"
+
+"@bazel/terser@^5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-5.1.0.tgz#5c82b93f4d9def8103c16be2dd33900d156fa066"
+  integrity sha512-uE3hTqfkZr4nvlk3jwi0xx6URqqI7r6GGPtDAU02/PVei+O4PfThaov7cwHO+D1FnoLncDqChb9Iolr7Crw/8A==
+
+"@bazel/typescript@^5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.1.0.tgz#348552355cc92a43f22e637fabce76ed64505548"
+  integrity sha512-E7wYv1tBFtcsFp0YN7Cf9Lv184xOzvT5WJKwZxt+43oq8R5tGmTSuqQwm4c9JmEq6s0eZmwUaRv+WXp9hxsE4A==
+  dependencies:
+    "@bazel/worker" "5.1.0"
+    protobufjs "6.8.8"
     semver "5.6.0"
     source-map-support "0.5.9"
-    tsutils "2.27.2"
+    tsutils "3.21.0"
+
+"@bazel/worker@5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.1.0.tgz#6f1e0f3ef628e3449d424cacd341c9abd09a3735"
+  integrity sha512-u3aU93UtHz3vL6ozezq0jnw83s1cNT4dAnW+vvB7M++YKFlB3CWzZFb0JRJbCp1b6DDe30ML0WOdd3nVYuylpw==
+  dependencies:
+    google-protobuf "^3.6.1"
 
 "@mrmlnc/readdir-enhanced@^2.2.1":
   version "2.2.1"
@@ -631,6 +655,18 @@
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
 
+"@sindresorhus/is@^0.14.0":
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
+  integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
+
+"@szmarczak/http-timer@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
+  integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==
+  dependencies:
+    defer-to-connect "^1.0.1"
+
 "@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"
@@ -706,6 +742,11 @@
   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"
@@ -747,6 +788,11 @@
   resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
   integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
 
+"@types/eslint-visitor-keys@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
+  integrity sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==
+
 "@types/estree@0.0.39":
   version "0.0.39"
   resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
@@ -852,6 +898,11 @@
   resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
   integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
 
+"@types/json-schema@^7.0.3":
+  version "7.0.5"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd"
+  integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==
+
 "@types/launchpad@^0.6.0":
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
@@ -879,6 +930,11 @@
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
   integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
+"@types/minimist@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
+  integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
+
 "@types/mz@0.0.29":
   version "0.0.29"
   resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.29.tgz#bc24728c649973f1c7851e9033f9ce525668c27b"
@@ -900,15 +956,20 @@
   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==
+  version "10.17.49"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.49.tgz#ecf0b67bab4b84d0ec9b0709db4aac3824a51c4a"
+  integrity sha512-PGaJNs5IZz5XgzwJvL/1zRfZB7iaJ5BydZ8/Picm+lUNYoNO9iVTQkVy5eUh0dZDrx3rBOIs3GCbCRmMuYyqwg==
 
 "@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/normalize-package-data@^2.4.0":
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
+  integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
+
 "@types/opn@^3.0.28":
   version "3.0.28"
   resolved "https://registry.yarnpkg.com/@types/opn/-/opn-3.0.28.tgz#097d0d1c9b5749573a5d96df132387bb6d02118a"
@@ -1183,6 +1244,49 @@
     "@types/events" "*"
     "@types/inquirer" "*"
 
+"@typescript-eslint/eslint-plugin@2.31.0":
+  version "2.31.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.31.0.tgz#942c921fec5e200b79593c71fafb1e3f57aa2e36"
+  integrity sha512-iIC0Pb8qDaoit+m80Ln/aaeu9zKQdOLF4SHcGLarSeY1gurW6aU4JsOPMjKQwXlw70MvWKZQc6S2NamA8SJ/gg==
+  dependencies:
+    "@typescript-eslint/experimental-utils" "2.31.0"
+    functional-red-black-tree "^1.0.1"
+    regexpp "^3.0.0"
+    tsutils "^3.17.1"
+
+"@typescript-eslint/experimental-utils@2.31.0":
+  version "2.31.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.31.0.tgz#a9ec514bf7fd5e5e82bc10dcb6a86d58baae9508"
+  integrity sha512-MI6IWkutLYQYTQgZ48IVnRXmLR/0Q6oAyJgiOror74arUMh7EWjJkADfirZhRsUMHeLJ85U2iySDwHTSnNi9vA==
+  dependencies:
+    "@types/json-schema" "^7.0.3"
+    "@typescript-eslint/typescript-estree" "2.31.0"
+    eslint-scope "^5.0.0"
+    eslint-utils "^2.0.0"
+
+"@typescript-eslint/parser@2.31.0":
+  version "2.31.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.31.0.tgz#beddd4e8efe64995108b229b2862cd5752d40d6f"
+  integrity sha512-uph+w6xUOlyV2DLSC6o+fBDzZ5i7+3/TxAsH4h3eC64tlga57oMb96vVlXoMwjR/nN+xyWlsnxtbDkB46M2EPQ==
+  dependencies:
+    "@types/eslint-visitor-keys" "^1.0.0"
+    "@typescript-eslint/experimental-utils" "2.31.0"
+    "@typescript-eslint/typescript-estree" "2.31.0"
+    eslint-visitor-keys "^1.1.0"
+
+"@typescript-eslint/typescript-estree@2.31.0":
+  version "2.31.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.31.0.tgz#ac536c2d46672aa1f27ba0ec2140d53670635cfd"
+  integrity sha512-vxW149bXFXXuBrAak0eKHOzbcu9cvi6iNcJDzEtOkRwGHxJG15chiAQAwhLOsk+86p9GTr/TziYvw+H9kMaIgA==
+  dependencies:
+    debug "^4.1.1"
+    eslint-visitor-keys "^1.1.0"
+    glob "^7.1.6"
+    is-glob "^4.0.1"
+    lodash "^4.17.15"
+    semver "^6.3.0"
+    tsutils "^3.17.1"
+
 "@webcomponents/webcomponentsjs@^1.0.7":
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
@@ -1289,6 +1393,13 @@
   dependencies:
     string-width "^2.0.0"
 
+ansi-align@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb"
+  integrity sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==
+  dependencies:
+    string-width "^3.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"
@@ -1338,6 +1449,14 @@
   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"
@@ -1508,6 +1627,11 @@
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
   integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
 
+arrify@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
+  integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
+
 asn1@~0.2.3:
   version "0.2.4"
   resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
@@ -2031,6 +2155,20 @@
     term-size "^1.2.0"
     widest-line "^2.0.0"
 
+boxen@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64"
+  integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==
+  dependencies:
+    ansi-align "^3.0.0"
+    camelcase "^5.3.1"
+    chalk "^3.0.0"
+    cli-boxes "^2.2.0"
+    string-width "^4.1.0"
+    term-size "^2.1.0"
+    type-fest "^0.8.1"
+    widest-line "^3.1.0"
+
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -2160,6 +2298,19 @@
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+cacheable-request@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912"
+  integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==
+  dependencies:
+    clone-response "^1.0.2"
+    get-stream "^5.1.0"
+    http-cache-semantics "^4.0.0"
+    keyv "^3.0.0"
+    lowercase-keys "^2.0.0"
+    normalize-url "^4.1.0"
+    responselike "^1.0.2"
+
 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"
@@ -2191,6 +2342,15 @@
     camelcase "^2.0.0"
     map-obj "^1.0.0"
 
+camelcase-keys@^6.2.2:
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0"
+  integrity sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==
+  dependencies:
+    camelcase "^5.3.1"
+    map-obj "^4.0.0"
+    quick-lru "^4.0.1"
+
 camelcase@^2.0.0, camelcase@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
@@ -2201,6 +2361,16 @@
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
   integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
 
+camelcase@^5.0.0, camelcase@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
+  integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
+
+camelcase@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e"
+  integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==
+
 cancel-token@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
@@ -2238,6 +2408,22 @@
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
+chalk@^3.0.0:
+  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@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
+  integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
 chalk@~0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f"
@@ -2288,6 +2474,11 @@
   resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
   integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
 
+ci-info@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
+  integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
+
 class-utils@^0.3.5:
   version "0.3.6"
   resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
@@ -2315,6 +2506,11 @@
   resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
   integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
 
+cli-boxes@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.0.tgz#538ecae8f9c6ca508e3c3c95b453fe93cb4c168d"
+  integrity sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==
+
 cli-cursor@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
@@ -2353,6 +2549,13 @@
   resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
   integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
 
+clone-response@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
+  integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
+  dependencies:
+    mimic-response "^1.0.0"
+
 clone-stats@^0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
@@ -2402,11 +2605,23 @@
   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, 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-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"
@@ -2485,7 +2700,7 @@
   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.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==
@@ -2597,6 +2812,18 @@
     write-file-atomic "^2.0.0"
     xdg-basedir "^3.0.0"
 
+configstore@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"
+  integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==
+  dependencies:
+    dot-prop "^5.2.0"
+    graceful-fs "^4.1.2"
+    make-dir "^3.0.0"
+    unique-string "^2.0.0"
+    write-file-atomic "^3.0.0"
+    xdg-basedir "^4.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"
@@ -2706,6 +2933,15 @@
     shebang-command "^1.2.0"
     which "^1.2.9"
 
+cross-spawn@^7.0.0:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
+  integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
+  dependencies:
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
+
 crypt@~0.0.1:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
@@ -2716,6 +2952,11 @@
   resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
   integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
 
+crypto-random-string@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
+  integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
+
 css-slam@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
@@ -2789,7 +3030,15 @@
   dependencies:
     ms "2.0.0"
 
-decamelize@^1.1.2:
+decamelize-keys@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9"
+  integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=
+  dependencies:
+    decamelize "^1.1.0"
+    map-obj "^1.0.0"
+
+decamelize@^1.1.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=
@@ -2799,7 +3048,7 @@
   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:
+decompress-response@^3.2.0, decompress-response@^3.3.0:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
   integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
@@ -2826,6 +3075,11 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.0.0.tgz#3e3110ca29205f120d7cb064960a39c3d2087c09"
   integrity sha512-YZ1rOP5+kHor4hMAH+HRQnBQHg+wvS1un1hAOuIcxcBy0hzcUf6Jg2a1w65kpoOUnurOfZbERwjI1TfZxNjcww==
 
+defer-to-connect@^1.0.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
+  integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==
+
 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"
@@ -3046,6 +3300,13 @@
   dependencies:
     is-obj "^1.0.0"
 
+dot-prop@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb"
+  integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==
+  dependencies:
+    is-obj "^2.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"
@@ -3260,6 +3521,11 @@
   resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.0.2.tgz#525c23725b8510f5f1f2feb5a1fbad93a93e29b4"
   integrity sha512-eO6vFm0JvqGzjWIQA6QVKjxpmELfhWbDUWHm1rPfIbn55mhKPiAa5xpLmQWJrNa629ZIeQ8ZvMAi13kvrjK6Mg==
 
+escape-goat@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
+  integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
+
 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"
@@ -3275,6 +3541,13 @@
   resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.13.0.tgz#e277d16d2cb25c1ffd3fd13fb0035ad7421382fe"
   integrity sha512-ELgMdOIpn0CFdsQS+FuxO+Ttu4p+aLaXHv9wA9yVnzqlUGV7oN/eRRnJekk7TCur6Cu2FXX0fqfIXRBaM14lpQ==
 
+eslint-config-prettier@^6.10.1:
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz#f6d2238c1290d01c859a8b5c1f7d352a0b0da8b1"
+  integrity sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA==
+  dependencies:
+    get-stdin "^6.0.0"
+
 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"
@@ -3291,6 +3564,14 @@
     debug "^2.6.9"
     pkg-dir "^2.0.0"
 
+eslint-plugin-es@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz#75a7cdfdccddc0589934aeeb384175f221c57893"
+  integrity sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==
+  dependencies:
+    eslint-utils "^2.0.0"
+    regexpp "^3.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"
@@ -3330,6 +3611,25 @@
     semver "^6.3.0"
     spdx-expression-parse "^3.0.0"
 
+eslint-plugin-node@^11.1.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d"
+  integrity sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==
+  dependencies:
+    eslint-plugin-es "^3.0.0"
+    eslint-utils "^2.0.0"
+    ignore "^5.1.1"
+    minimatch "^3.0.4"
+    resolve "^1.10.1"
+    semver "^6.1.0"
+
+eslint-plugin-prettier@^3.1.2:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz#168ab43154e2ea57db992a2cd097c828171f75c2"
+  integrity sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg==
+  dependencies:
+    prettier-linter-helpers "^1.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"
@@ -3352,12 +3652,19 @@
   dependencies:
     eslint-visitor-keys "^1.1.0"
 
+eslint-utils@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27"
+  integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
+  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:
+eslint@^6.6.0, eslint@^6.8.0:
   version "6.8.0"
   resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb"
   integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==
@@ -3482,6 +3789,21 @@
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
+execa@^4.0.0:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-4.0.2.tgz#ad87fb7b2d9d564f70d2b62d511bee41d5cbb240"
+  integrity sha512-QI2zLa6CjGWdiQsmSkZoGtDx2N+cQIGb3yNolGTdjSQzydzLgYYf8LRuagp7S7fPimjcrzUDSUFd/MgzELMi4Q==
+  dependencies:
+    cross-spawn "^7.0.0"
+    get-stream "^5.0.0"
+    human-signals "^1.1.1"
+    is-stream "^2.0.0"
+    merge-stream "^2.0.0"
+    npm-run-path "^4.0.0"
+    onetime "^5.1.0"
+    signal-exit "^3.0.2"
+    strip-final-newline "^2.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"
@@ -3803,6 +4125,14 @@
   dependencies:
     locate-path "^3.0.0"
 
+find-up@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.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"
@@ -3946,6 +4276,11 @@
     nan "^2.12.1"
     node-pre-gyp "^0.12.0"
 
+fsevents@~2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
+  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -3975,18 +4310,30 @@
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
   integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
 
+get-stdin@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b"
+  integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==
+
 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:
+get-stream@^4.0.0, get-stream@^4.1.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-stream@^5.0.0, get-stream@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9"
+  integrity sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==
+  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"
@@ -4097,7 +4444,7 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^7.1.3, glob@^7.1.4:
+glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -4116,6 +4463,13 @@
   dependencies:
     ini "^1.3.4"
 
+global-dirs@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201"
+  integrity sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==
+  dependencies:
+    ini "^1.3.5"
+
 global-modules@^0.2.3:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d"
@@ -4207,6 +4561,11 @@
     pify "^3.0.0"
     slash "^1.0.0"
 
+google-protobuf@^3.6.1:
+  version "3.19.4"
+  resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.19.4.tgz#8d32c3e34be9250956f28c0fb90955d13f311888"
+  integrity sha512-OIPNCxsG2lkIvf+P5FNfJ/Km95CsXOBecS9ZcAU6m2Rq3svc0Apl9nB3GMDNKfQ9asNv4KjyAqGwPQFrVle3Yg==
+
 got@^5.0.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35"
@@ -4265,6 +4624,23 @@
     url-parse-lax "^1.0.0"
     url-to-options "^1.0.1"
 
+got@^9.6.0:
+  version "9.6.0"
+  resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85"
+  integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==
+  dependencies:
+    "@sindresorhus/is" "^0.14.0"
+    "@szmarczak/http-timer" "^1.1.2"
+    cacheable-request "^6.0.0"
+    decompress-response "^3.3.0"
+    duplexer3 "^0.1.4"
+    get-stream "^4.1.0"
+    lowercase-keys "^1.0.1"
+    mimic-response "^1.0.1"
+    p-cancelable "^1.0.0"
+    to-readable-stream "^1.0.0"
+    url-parse-lax "^3.0.0"
+
 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"
@@ -4282,6 +4658,27 @@
   dependencies:
     lodash "^4.17.2"
 
+gts@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/gts/-/gts-2.0.2.tgz#b8b28de99361b5c5c24db30a375a0f546bbc04a4"
+  integrity sha512-SLytzl2IqKXf6kGULwr07XQ9lVsvjrzFD3OAA7DEfIQYuD+lKBPt/cZ/RYGxaWerY4PTfmnXT7KdxEr9Ec8uHQ==
+  dependencies:
+    "@typescript-eslint/eslint-plugin" "2.31.0"
+    "@typescript-eslint/parser" "2.31.0"
+    chalk "^4.0.0"
+    eslint "^6.8.0"
+    eslint-config-prettier "^6.10.1"
+    eslint-plugin-node "^11.1.0"
+    eslint-plugin-prettier "^3.1.2"
+    execa "^4.0.0"
+    inquirer "^7.1.0"
+    meow "^7.0.0"
+    ncp "^2.0.0"
+    prettier "^2.0.4"
+    rimraf "^3.0.2"
+    update-notifier "^4.1.0"
+    write-file-atomic "^3.0.3"
+
 gulp-if@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
@@ -4339,6 +4736,11 @@
     ajv "^6.5.5"
     har-schema "^2.0.0"
 
+hard-rejection@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
+  integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==
+
 has-ansi@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@@ -4368,6 +4770,11 @@
   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"
@@ -4426,6 +4833,11 @@
     is-number "^3.0.0"
     kind-of "^4.0.0"
 
+has-yarn@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77"
+  integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==
+
 has@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
@@ -4485,6 +4897,11 @@
     inherits "^2.0.1"
     readable-stream "^3.1.1"
 
+http-cache-semantics@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
+  integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
+
 http-deceiver@^1.2.7:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
@@ -4555,6 +4972,11 @@
     agent-base "^4.3.0"
     debug "^3.1.0"
 
+human-signals@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
+  integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
+
 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"
@@ -4584,6 +5006,11 @@
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
   integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
 
+ignore@^5.1.1:
+  version "5.1.8"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
+  integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
+
 import-fresh@^3.0.0:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"
@@ -4609,6 +5036,11 @@
   dependencies:
     repeating "^2.0.0"
 
+indent-string@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
+  integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
+
 indent@0.0.2:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
@@ -4637,7 +5069,7 @@
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
   integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 
-ini@^1.3.4, ini@~1.3.0:
+ini@^1.3.4, ini@^1.3.5, 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==
@@ -4700,6 +5132,25 @@
     strip-ansi "^5.1.0"
     through "^2.3.6"
 
+inquirer@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.2.0.tgz#63ce99d823090de7eb420e4bb05e6f3449aa389a"
+  integrity sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ==
+  dependencies:
+    ansi-escapes "^4.2.1"
+    chalk "^3.0.0"
+    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.4.0"
+    rxjs "^6.5.3"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.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"
@@ -4770,6 +5221,13 @@
   dependencies:
     ci-info "^1.5.0"
 
+is-ci@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
+  integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==
+  dependencies:
+    ci-info "^2.0.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"
@@ -4904,11 +5362,24 @@
     global-dirs "^0.1.0"
     is-path-inside "^1.0.0"
 
+is-installed-globally@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141"
+  integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==
+  dependencies:
+    global-dirs "^2.0.1"
+    is-path-inside "^3.0.1"
+
 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-npm@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d"
+  integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==
+
 is-number@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
@@ -4933,6 +5404,11 @@
   resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
   integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
 
+is-obj@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982"
+  integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==
+
 is-object@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
@@ -4957,6 +5433,11 @@
   dependencies:
     path-is-inside "^1.0.1"
 
+is-path-inside@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017"
+  integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==
+
 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"
@@ -5025,6 +5506,11 @@
   resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
   integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
 
+is-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3"
+  integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==
+
 is-string@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
@@ -5037,7 +5523,7 @@
   dependencies:
     has-symbols "^1.0.1"
 
-is-typedarray@~1.0.0:
+is-typedarray@^1.0.0, 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=
@@ -5062,6 +5548,11 @@
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
   integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
 
+is-yarn-global@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232"
+  integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==
+
 isarray@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
@@ -5171,6 +5662,11 @@
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
   integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
 
+json-buffer@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
+  integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
+
 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"
@@ -5218,6 +5714,13 @@
     json-schema "0.2.3"
     verror "1.10.0"
 
+keyv@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
+  integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==
+  dependencies:
+    json-buffer "3.0.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"
@@ -5242,6 +5745,11 @@
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
   integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==
 
+kind-of@^6.0.3:
+  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"
@@ -5263,6 +5771,13 @@
   dependencies:
     package-json "^4.0.0"
 
+latest-version@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face"
+  integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==
+  dependencies:
+    package-json "^6.3.0"
+
 launchpad@^0.7.0:
   version "0.7.4"
   resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.4.tgz#08a7a38f48b963e73dc68be84f9f8f974c46c26b"
@@ -5297,6 +5812,11 @@
     prelude-ls "~1.1.2"
     type-check "~0.3.2"
 
+lines-and-columns@^1.1.6:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
+  integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
+
 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"
@@ -5344,6 +5864,13 @@
     p-locate "^3.0.0"
     path-exists "^3.0.0"
 
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
+  dependencies:
+    p-locate "^4.1.0"
+
 lodash._reinterpolate@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
@@ -5510,11 +6037,16 @@
   resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
   integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
 
-lowercase-keys@^1.0.0:
+lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
   integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
 
+lowercase-keys@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
+  integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
+
 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"
@@ -5542,6 +6074,13 @@
   dependencies:
     pify "^3.0.0"
 
+make-dir@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.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"
@@ -5552,6 +6091,11 @@
   resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
   integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
 
+map-obj@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.1.0.tgz#b91221b542734b9f14256c0132c897c5d7256fd5"
+  integrity sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==
+
 map-visit@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
@@ -5627,6 +6171,25 @@
     redent "^1.0.0"
     trim-newlines "^1.0.0"
 
+meow@^7.0.0:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/meow/-/meow-7.0.1.tgz#1ed4a0a50b3844b451369c48362eb0515f04c1dc"
+  integrity sha512-tBKIQqVrAHqwit0vfuFPY3LlzJYkEOFyKa3bPgxzNl6q/RtN8KQ+ALYEASYuFayzSAsjlhXj/JZ10rH85Q6TUw==
+  dependencies:
+    "@types/minimist" "^1.2.0"
+    arrify "^2.0.1"
+    camelcase "^6.0.0"
+    camelcase-keys "^6.2.2"
+    decamelize-keys "^1.1.0"
+    hard-rejection "^2.1.0"
+    minimist-options "^4.0.2"
+    normalize-package-data "^2.5.0"
+    read-pkg-up "^7.0.1"
+    redent "^3.0.0"
+    trim-newlines "^3.0.0"
+    type-fest "^0.13.1"
+    yargs-parser "^18.1.3"
+
 merge-descriptors@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
@@ -5639,6 +6202,11 @@
   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.2.3"
   resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.3.tgz#7ee99dbd69bb6481689253f018488a1b902b0ed5"
@@ -5736,11 +6304,16 @@
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
   integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
-mimic-response@^1.0.0:
+mimic-response@^1.0.0, mimic-response@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
   integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
 
+min-indent@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
+  integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
+
 minimalistic-assert@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
@@ -5760,6 +6333,15 @@
   dependencies:
     brace-expansion "^1.1.7"
 
+minimist-options@^4.0.2:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
+  integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==
+  dependencies:
+    arrify "^1.0.1"
+    is-plain-obj "^1.1.0"
+    kind-of "^6.0.3"
+
 minimist@0.0.8, minimist@~0.0.1:
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
@@ -5908,6 +6490,11 @@
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
 
+ncp@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
+  integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
+
 needle@^2.2.1:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.0.tgz#ce3fea21197267bacb310705a7bbe24f2a3a3492"
@@ -5976,7 +6563,7 @@
     abbrev "1"
     osenv "^0.1.4"
 
-normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
   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==
@@ -5998,6 +6585,11 @@
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
   integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
 
+normalize-url@^4.1.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129"
+  integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==
+
 npm-bundled@^1.0.1:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
@@ -6018,6 +6610,13 @@
   dependencies:
     path-key "^2.0.0"
 
+npm-run-path@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
+  integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
+  dependencies:
+    path-key "^3.0.0"
+
 npmlog@^4.0.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
@@ -6238,6 +6837,11 @@
   resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
   integrity sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==
 
+p-cancelable@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
+  integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==
+
 p-finally@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
@@ -6257,6 +6861,13 @@
   dependencies:
     p-try "^2.0.0"
 
+p-limit@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+  integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
+  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"
@@ -6271,6 +6882,13 @@
   dependencies:
     p-limit "^2.0.0"
 
+p-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
 p-map@^1.1.1:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
@@ -6313,6 +6931,16 @@
     registry-url "^3.0.3"
     semver "^5.1.0"
 
+package-json@^6.3.0:
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0"
+  integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==
+  dependencies:
+    got "^9.6.0"
+    registry-auth-token "^4.0.0"
+    registry-url "^5.0.0"
+    semver "^6.2.0"
+
 pako@~0.2.0:
   version "0.2.9"
   resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
@@ -6357,6 +6985,16 @@
     error-ex "^1.3.1"
     json-parse-better-errors "^1.0.1"
 
+parse-json@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.0.0.tgz#73e5114c986d143efa3712d4ea24db9a4266f60f"
+  integrity sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    error-ex "^1.3.1"
+    json-parse-better-errors "^1.0.1"
+    lines-and-columns "^1.1.6"
+
 parse-passwd@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
@@ -6408,6 +7046,11 @@
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
   integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
 
+path-exists@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+  integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
 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"
@@ -6423,6 +7066,11 @@
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
   integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
 
+path-key@^3.0.0, path-key@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+  integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
 path-parse@^1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
@@ -6832,6 +7480,11 @@
   resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
   integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
 
+prepend-http@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
+  integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
+
 preserve@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
@@ -6844,7 +7497,7 @@
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@2.0.5:
+prettier@2.0.5, prettier@^2.0.4:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4"
   integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==
@@ -6954,6 +7607,13 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
+pupa@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726"
+  integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==
+  dependencies:
+    escape-goat "^2.0.0"
+
 q@^1.4.1, q@^1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
@@ -6969,6 +7629,11 @@
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
   integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
 
+quick-lru@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
+  integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
+
 randomatic@^3.0.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
@@ -6993,7 +7658,7 @@
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
-rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
+rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8:
   version "1.2.8"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
   integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@@ -7043,6 +7708,15 @@
     find-up "^3.0.0"
     read-pkg "^3.0.0"
 
+read-pkg-up@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
+  integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==
+  dependencies:
+    find-up "^4.1.0"
+    read-pkg "^5.2.0"
+    type-fest "^0.8.1"
+
 read-pkg@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
@@ -7070,6 +7744,16 @@
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
+read-pkg@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
+  integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
+  dependencies:
+    "@types/normalize-package-data" "^2.4.0"
+    normalize-package-data "^2.5.0"
+    parse-json "^5.0.0"
+    type-fest "^0.6.0"
+
 readable-stream@1.1.x:
   version "1.1.14"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
@@ -7149,6 +7833,14 @@
     indent-string "^2.1.0"
     strip-indent "^1.0.1"
 
+redent@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
+  integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==
+  dependencies:
+    indent-string "^4.0.0"
+    strip-indent "^3.0.0"
+
 reduce-flatten@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
@@ -7198,6 +7890,11 @@
   resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
   integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==
 
+regexpp@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2"
+  integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==
+
 regexpu-core@^4.5.4:
   version "4.5.4"
   resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.5.4.tgz#080d9d02289aa87fe1667a4f5136bc98a6aebaae"
@@ -7223,6 +7920,13 @@
     rc "^1.1.6"
     safe-buffer "^5.0.1"
 
+registry-auth-token@^4.0.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.1.1.tgz#40a33be1e82539460f94328b0f7f0f84c16d9479"
+  integrity sha512-9bKS7nTl9+/A1s7tnPeGrUpRcVY+LUh7bfFgzpndALdPfXQBfQV77rQVtqgUV3ti4vc/Ik81Ex8UJDWDQ12zQA==
+  dependencies:
+    rc "^1.2.8"
+
 registry-url@^3.0.3:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
@@ -7230,6 +7934,13 @@
   dependencies:
     rc "^1.0.1"
 
+registry-url@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009"
+  integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==
+  dependencies:
+    rc "^1.2.8"
+
 regjsgen@^0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.0.tgz#a7634dc08f89209c2049adda3525711fb97265dd"
@@ -7348,6 +8059,13 @@
   dependencies:
     path-parse "^1.0.6"
 
+resolve@^1.10.1:
+  version "1.17.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444"
+  integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==
+  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"
@@ -7362,6 +8080,13 @@
   dependencies:
     path-parse "^1.0.6"
 
+responselike@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
+  integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=
+  dependencies:
+    lowercase-keys "^1.0.0"
+
 restore-cursor@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
@@ -7405,6 +8130,13 @@
   dependencies:
     glob "^7.1.3"
 
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  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"
@@ -7419,6 +8151,13 @@
     "@types/node" "^12.0.10"
     acorn "^6.1.1"
 
+rollup@^2.3.4:
+  version "2.35.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.35.1.tgz#e6bc8d10893556a638066f89e8c97f422d03968c"
+  integrity sha512-q5KxEyWpprAIcainhVy6HfRttD9kutQpHbeqDTWnqAFNJotiojetK6uqmcydNMymBEtC4I8bCYR+J3mTMqeaUA==
+  optionalDependencies:
+    fsevents "~2.1.2"
+
 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"
@@ -7426,6 +8165,11 @@
   dependencies:
     is-promise "^2.1.0"
 
+run-async@^2.4.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
+  integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==
+
 rx@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
@@ -7524,6 +8268,13 @@
   dependencies:
     semver "^5.0.3"
 
+semver-diff@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b"
+  integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==
+  dependencies:
+    semver "^6.3.0"
+
 "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"
@@ -7534,7 +8285,7 @@
   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:
+semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.2.0, 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==
@@ -7634,11 +8385,23 @@
   dependencies:
     shebang-regex "^1.0.0"
 
+shebang-command@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+  dependencies:
+    shebang-regex "^3.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=
 
+shebang-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
 shelljs@^0.8.0:
   version "0.8.3"
   resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.3.tgz#a7f3319520ebf09ee81275b2368adb286659b097"
@@ -7816,6 +8579,14 @@
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
+source-map-support@~0.5.12:
+  version "0.5.19"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
+  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
+  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"
@@ -8001,7 +8772,7 @@
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^5.1.0"
 
-string-width@^4.1.0:
+string-width@^4.0.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==
@@ -8111,6 +8882,11 @@
   resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
   integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
 
+strip-final-newline@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
+  integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
+
 strip-indent@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
@@ -8123,6 +8899,13 @@
   resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
   integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
 
+strip-indent@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001"
+  integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==
+  dependencies:
+    min-indent "^1.0.0"
+
 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"
@@ -8145,6 +8928,13 @@
   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"
@@ -8270,6 +9060,11 @@
   dependencies:
     execa "^0.7.0"
 
+term-size@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753"
+  integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==
+
 ternary-stream@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.0.1.tgz#064e489b4b5bf60ba6a6b7bc7f2f5c274ecf8269"
@@ -8280,6 +9075,15 @@
     merge-stream "^1.0.0"
     through2 "^2.0.1"
 
+terser@^4.8.0:
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
+  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
+  dependencies:
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
+
 text-encoding@0.6.4:
   version "0.6.4"
   resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
@@ -8416,6 +9220,11 @@
   dependencies:
     kind-of "^3.0.2"
 
+to-readable-stream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771"
+  integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==
+
 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"
@@ -8459,6 +9268,11 @@
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
   integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
 
+trim-newlines@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30"
+  integrity sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==
+
 trim-right@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
@@ -8469,15 +9283,27 @@
   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:
+tslib@^1.8.1:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+
+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==
+tsutils@3.21.0:
+  version "3.21.0"
+  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
+  integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==
+  dependencies:
+    tslib "^1.8.1"
+
+tsutils@^3.17.1:
+  version "3.17.1"
+  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
+  integrity sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==
   dependencies:
     tslib "^1.8.1"
 
@@ -8505,6 +9331,16 @@
   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.13.1:
+  version "0.13.1"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934"
+  integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==
+
+type-fest@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
+  integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
+
 type-fest@^0.8.1:
   version "0.8.1"
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
@@ -8518,15 +9354,22 @@
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
+typedarray-to-buffer@^3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
+  integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==
+  dependencies:
+    is-typedarray "^1.0.0"
+
 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==
+typescript@3.9.5:
+  version "3.9.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36"
+  integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==
 
 typical@^2.6.1:
   version "2.6.1"
@@ -8609,6 +9452,13 @@
   dependencies:
     crypto-random-string "^1.0.0"
 
+unique-string@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d"
+  integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==
+  dependencies:
+    crypto-random-string "^2.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"
@@ -8681,6 +9531,25 @@
     semver-diff "^2.0.0"
     xdg-basedir "^3.0.0"
 
+update-notifier@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.0.tgz#4866b98c3bc5b5473c020b1250583628f9a328f3"
+  integrity sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew==
+  dependencies:
+    boxen "^4.2.0"
+    chalk "^3.0.0"
+    configstore "^5.0.1"
+    has-yarn "^2.1.0"
+    import-lazy "^2.1.0"
+    is-ci "^2.0.0"
+    is-installed-globally "^0.3.1"
+    is-npm "^4.0.0"
+    is-yarn-global "^0.3.0"
+    latest-version "^5.0.0"
+    pupa "^2.0.1"
+    semver-diff "^3.1.1"
+    xdg-basedir "^4.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"
@@ -8715,6 +9584,13 @@
   dependencies:
     prepend-http "^1.0.1"
 
+url-parse-lax@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c"
+  integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=
+  dependencies:
+    prepend-http "^2.0.0"
+
 url-template@^2.0.8:
   version "2.0.8"
   resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
@@ -8911,7 +9787,7 @@
     request "2.88.0"
     vargs "^0.1.0"
 
-web-component-tester@^6.5.1, web-component-tester@^6.9.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==
@@ -8967,6 +9843,13 @@
   dependencies:
     isexe "^2.0.0"
 
+which@^2.0.1:
+  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"
+
 wide-align@^1.1.0:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
@@ -8988,6 +9871,13 @@
   dependencies:
     string-width "^2.1.1"
 
+widest-line@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca"
+  integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==
+  dependencies:
+    string-width "^4.0.0"
+
 windows-release@^3.1.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f"
@@ -9068,6 +9958,16 @@
     imurmurhash "^0.1.4"
     signal-exit "^3.0.2"
 
+write-file-atomic@^3.0.0, write-file-atomic@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8"
+  integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==
+  dependencies:
+    imurmurhash "^0.1.4"
+    is-typedarray "^1.0.0"
+    signal-exit "^3.0.2"
+    typedarray-to-buffer "^3.1.5"
+
 write@1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"
@@ -9099,6 +9999,11 @@
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
   integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
 
+xdg-basedir@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
+  integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
+
 xmlbuilder@8.2.2:
   version "8.2.2"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
@@ -9129,6 +10034,14 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9"
   integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==
 
+yargs-parser@^18.1.3:
+  version "18.1.3"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
+  integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
+  dependencies:
+    camelcase "^5.0.0"
+    decamelize "^1.2.0"
+
 yauzl@^2.10.0:
   version "2.10.0"
   resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"